Compare commits
224 Commits
feb6c74f5c
...
c2b107a8bd
Author | SHA1 | Date |
---|---|---|
Yush0DAN | c2b107a8bd | |
ringosham | 7614a8d083 | |
AwkwardPeak7 | d11495d8a1 | |
Vetle Ledaal | 33afc95944 | |
Vetle Ledaal | 3862f49ad7 | |
AwkwardPeak7 | 39475d8916 | |
CriosChan | cb4c648b19 | |
Evrey | cb1d65d02c | |
Vetle Ledaal | 13474b911f | |
Vetle Ledaal | 8c19cc40ca | |
Vetle Ledaal | 9fb6efe03d | |
kana-shii | f99c22a81d | |
kana-shii | b19127100b | |
Vetle Ledaal | e411215caf | |
Vetle Ledaal | 979b248658 | |
Vetle Ledaal | 9d336d1645 | |
Vetle Ledaal | d6420c1545 | |
Vetle Ledaal | 0a11da38bf | |
Yush0DAN | 1e44e00a0b | |
Vetle Ledaal | 7871c9a134 | |
Yush0DAN | 37a6515ba7 | |
inipew | c77fc83f7d | |
Vetle Ledaal | 9bfc114777 | |
Yush0DAN | 7ae20d9e2e | |
KenjieDec | ef68b19ebe | |
Vetle Ledaal | 556ddb2d80 | |
Yush0DAN | 1d09677e3b | |
ringosham | 2075d64909 | |
Vetle Ledaal | 28ccd54565 | |
Vetle Ledaal | 121f0591db | |
AwkwardPeak7 | 5b920b207a | |
Vetle Ledaal | 74c35b8734 | |
Vetle Ledaal | 35d16330b1 | |
Vetle Ledaal | 027c22b5de | |
Vetle Ledaal | fc20cb2228 | |
Vetle Ledaal | 0eb1915ee0 | |
Vetle Ledaal | c71cb97a49 | |
Yush0DAN | 50e0f8eb8c | |
Vetle Ledaal | 5a87d9f16e | |
Vetle Ledaal | 75fe6a5d71 | |
Chopper | a916c6a4a1 | |
Vetle Ledaal | 8dc7c90005 | |
KenjieDec | 102b91f959 | |
ringosham | 5cebd2a63a | |
Secozzi | 1518e5b867 | |
Chopper | 6f6de5c4b3 | |
bapeey | ab6f97849e | |
bapeey | 6f9ef9cfdf | |
Chopper | 0f2a2f619a | |
bapeey | 0a894b790c | |
Chopper | 0018f3d8d7 | |
Chopper | 96b4c07988 | |
Chopper | 3c60b13b82 | |
Chopper | 6047e02345 | |
AwkwardPeak7 | 7d607486c4 | |
KirinRaikage | 99539e261e | |
KenjieDec | 89387d0f08 | |
Yush0DAN | 077c62543e | |
Yush0DAN | 7445751170 | |
Yush0DAN | 63f4b9af12 | |
KenjieDec | 2dbf798f0c | |
bapeey | 4d764ef0a9 | |
bapeey | e17e45ca77 | |
Vetle Ledaal | 6986a260a8 | |
Chopper | 689e4a1ba1 | |
Chopper | 5b0a105213 | |
AwkwardPeak7 | b5b65b7be4 | |
Vetle Ledaal | 5942e0944e | |
KenjieDec | ad666e8aa7 | |
Chopper | 96cde60458 | |
Chopper | c26616dfc5 | |
Vetle Ledaal | 26f1973c8a | |
bapeey | 12285c5235 | |
Vetle Ledaal | 6fb12f2659 | |
Vetle Ledaal | fe13a3ac13 | |
Chopper | cdddaead3e | |
Vetle Ledaal | d9d939ede3 | |
Vetle Ledaal | fe23485b11 | |
Vetle Ledaal | 30d9450b38 | |
Luqman | af9252e798 | |
Smol Ame | 72905b0433 | |
Smol Ame | 8447cc8635 | |
KenjieDec | 780089af90 | |
Smol Ame | 82b7531d00 | |
bapeey | 56773e3686 | |
bapeey | dcd3bc015d | |
KenjieDec | 592645fa9d | |
bapeey | 73984b1dcf | |
Yush0DAN | 445ce09211 | |
Chopper | c8825e0bc4 | |
inipew | 4b996a2976 | |
Vetle Ledaal | 56adddffa3 | |
Chopper | b6b0803bb8 | |
bapeey | be0f6c90fd | |
Vetle Ledaal | 947c34f4c8 | |
Vetle Ledaal | bd622ff86a | |
Yush0DAN | 5a91a014ca | |
Luqman | dc3f37a284 | |
Vetle Ledaal | 0765cd7f3d | |
Vetle Ledaal | a2b8ecbc12 | |
Vetle Ledaal | b11603f86b | |
Vetle Ledaal | e5f63b46c7 | |
AlphaBoom | b6cba48ba7 | |
Vetle Ledaal | bfbdf30ab9 | |
AwkwardPeak7 | 4aa62d9b06 | |
Luqman | 16d776056b | |
Chopper | 14eefec146 | |
Yush0DAN | d55da9562b | |
inipew | 24b07673f9 | |
Chopper | 8f7c89db70 | |
Chopper | d075391602 | |
Chopper | d6512ee2f5 | |
Chopper | 15f0c1ff34 | |
Chopper | 51e5bd5027 | |
Vetle Ledaal | 10afe22672 | |
bapeey | 0088545740 | |
KirinRaikage | 5e93a65f4d | |
Chopper | 314424aa43 | |
bapeey | e46b669169 | |
AwkwardPeak7 | 02ddcb00e6 | |
bapeey | 47b60ed24d | |
bapeey | 731bcf021d | |
bapeey | b6d14247af | |
bapeey | 96335c8575 | |
Chopper | 590c0bb2dc | |
bapeey | 795c6f73e2 | |
Vetle Ledaal | 4428cd7351 | |
Vetle Ledaal | c6df567713 | |
bapeey | eab9ce09d2 | |
bapeey | d83d23685f | |
Chopper | c2797eed13 | |
Chopper | cd4ef71b1c | |
Chopper | f814878dbb | |
bapeey | ff3f613886 | |
AlphaBoom | 1bbb002b4f | |
bapeey | a13690f1e5 | |
bapeey | f241b03d61 | |
Vetle Ledaal | 353c7e086f | |
Vetle Ledaal | 4ed4e369a5 | |
Chopper | 7465cbf353 | |
Vetle Ledaal | c7d347ed43 | |
Vetle Ledaal | 953704c200 | |
Vetle Ledaal | 2e5e25118e | |
Vetle Ledaal | 43ef813e39 | |
Vetle Ledaal | dacef7b01c | |
Vetle Ledaal | 9588f571d1 | |
AwkwardPeak7 | b6c20bf147 | |
AwkwardPeak7 | 927897c78b | |
bapeey | e710fee5c5 | |
Vetle Ledaal | e0d4925ae9 | |
Vetle Ledaal | 19c013a9cf | |
mohamedotaku | 54b7de7eba | |
Chopper | 837d6b515f | |
Chopper | 6b09f203d7 | |
Chopper | 10c539c869 | |
KenjieDec | a910431f44 | |
Chopper | 434a03bd78 | |
bapeey | c33fe52809 | |
Chopper | 0c3332edf7 | |
Chopper | c322f75562 | |
Chopper | 770f7ba5a0 | |
bapeey | 6617b33598 | |
Chopper | 29624cca71 | |
Chopper | a75d6f4670 | |
Fermín Cirella | b94598c7f6 | |
Smol Ame | 4cdce2bc1b | |
Smol Ame | 4c4a178b9e | |
Eshlender | 88205d95b0 | |
Chopper | 09868c7506 | |
AwkwardPeak7 | 1f4c56a57c | |
Eshlender | 681bde548b | |
Chopper | 5e0c731898 | |
bapeey | 03c18aa430 | |
AwkwardPeak7 | df5f859c1e | |
Smol Ame | d2cb60d570 | |
Smol Ame | bdcd561891 | |
Chopper | 7d37aeac62 | |
Denis Akazuki | b4b811cbc4 | |
Chopper | f1364e9c91 | |
Chopper | 058aeaa54e | |
Chopper | ae52c158c3 | |
nedius | f98063068b | |
AwkwardPeak7 | eb6b5d39d7 | |
Smol Ame | 9945575661 | |
Smol Ame | 13f372bd12 | |
Smol Ame | af196dc76d | |
Vetle Ledaal | 9f164f1b58 | |
KenjieDec | 254087d912 | |
DOTX | 25c9212526 | |
Fansubs.cat | fc316415eb | |
bapeey | fd68b8e9b8 | |
inipew | 99ee352f49 | |
Vetle Ledaal | b58cbfae1d | |
Chopper | 163cb85833 | |
KenjieDec | 73ff0e28e9 | |
Eshlender | 2f234f7a93 | |
sinkableShip | d7c93faeb1 | |
Mylloon | f29eb16762 | |
mohamedotaku | a4dece2c77 | |
bapeey | 8d5ab22c9a | |
Cuong M. Tran | 7f87376a73 | |
Chopper | ce5160d32a | |
bapeey | acaa33bc24 | |
Chopper | d1e9584966 | |
Smol Ame | c26fee2dcd | |
AwkwardPeak7 | 9b1633f28e | |
Chopper | 00ff44e696 | |
Chopper | 05dfff4465 | |
Chopper | cae812f456 | |
Chopper | 8106a2bab9 | |
KenjieDec | 41e43420b5 | |
AwkwardPeak7 | 76fe2af9ca | |
Chopper | 61b0ab972d | |
nedius | 270e70125c | |
stevenyomi | e65117d877 | |
KenjieDec | 95e4d83106 | |
bapeey | 0a170de120 | |
Smol Ame | 6b77db8c62 | |
bapeey | 030ebd09c6 | |
Smol Ame | b64d60e7ed | |
AwkwardPeak7 | 8e28453769 | |
AwkwardPeak7 | 66edae8b60 | |
ZIDOUZI | ac3a77ef28 | |
stevenyomi | c66844143c |
|
@ -720,6 +720,10 @@ And for a release build of Tachiyomi:
|
|||
|
||||
### Android Debugger
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you didn't build the main app from source with debug enabled and are using a release/beta APK, you **need** a rooted device.
|
||||
> If you are using an emulator instead, make sure you choose a profile **without** Google Play.
|
||||
|
||||
You can leverage the Android Debugger to step through your extension while debugging.
|
||||
|
||||
You *cannot* simply use Android Studio's `Debug 'module.name'` -> this will most likely result in an
|
||||
|
|
|
@ -63,6 +63,7 @@ android {
|
|||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
vcsInfo.include false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,6 +75,10 @@ android {
|
|||
buildConfig true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes.add("kotlin-tooling-metadata.json")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
|
|
|
@ -4,7 +4,7 @@ coroutines_version = "1.6.4"
|
|||
serialization_version = "1.4.0"
|
||||
|
||||
[libraries]
|
||||
gradle-agp = { module = "com.android.tools.build:gradle", version = "8.2.1" }
|
||||
gradle-agp = { module = "com.android.tools.build:gradle", version = "8.4.1" }
|
||||
gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" }
|
||||
gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" }
|
||||
gradle-kotlinter = { module = "org.jmailen.gradle:kotlinter-gradle", version = "3.13.0" }
|
||||
|
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
||||
baseVersionCode = 5
|
||||
|
|
|
@ -30,11 +30,10 @@ abstract class FansubsCat(
|
|||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
val apiBaseUrl: String,
|
||||
val isHentaiSite: Boolean,
|
||||
) : HttpSource() {
|
||||
|
||||
private val apiBaseUrl = "https://api.fansubs.cat"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
|
@ -91,7 +90,7 @@ abstract class FansubsCat(
|
|||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiBaseUrl/manga/popular/$page?hentai=$isHentaiSite", headers)
|
||||
return GET("$apiBaseUrl/manga/popular/$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = parseMangaFromJson(response)
|
||||
|
@ -99,7 +98,7 @@ abstract class FansubsCat(
|
|||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiBaseUrl/manga/recent/$page?hentai=$isHentaiSite", headers)
|
||||
return GET("$apiBaseUrl/manga/recent/$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = parseMangaFromJson(response)
|
||||
|
@ -110,13 +109,15 @@ abstract class FansubsCat(
|
|||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val mangaTypeFilter = filterList.find { it is MangaTypeFilter } as MangaTypeFilter
|
||||
val stateFilter = filterList.find { it is StateFilter } as StateFilter
|
||||
val demographyFilter = filterList.find { it is DemographyFilter } as DemographyFilter
|
||||
val genreFilter = filterList.find { it is GenreTagFilter } as GenreTagFilter
|
||||
val themeFilter = filterList.find { it is ThemeTagFilter } as ThemeTagFilter
|
||||
val builder = "$apiBaseUrl/manga/search/$page?hentai=$isHentaiSite".toHttpUrl().newBuilder()
|
||||
val builder = "$apiBaseUrl/manga/search/$page".toHttpUrl().newBuilder()
|
||||
mangaTypeFilter.addQueryParameter(builder)
|
||||
stateFilter.addQueryParameter(builder)
|
||||
demographyFilter.addQueryParameter(builder)
|
||||
if (!isHentaiSite) {
|
||||
val demographyFilter = filterList.find { it is DemographyFilter } as DemographyFilter
|
||||
demographyFilter.addQueryParameter(builder)
|
||||
}
|
||||
genreFilter.addQueryParameter(builder)
|
||||
themeFilter.addQueryParameter(builder)
|
||||
if (query.isNotBlank()) {
|
||||
|
@ -131,7 +132,7 @@ abstract class FansubsCat(
|
|||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/details/${manga.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
"$apiBaseUrl/manga/details/${manga.url.substringAfterLast('/')}",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
@ -166,7 +167,7 @@ abstract class FansubsCat(
|
|||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/chapters/${manga.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
"$apiBaseUrl/manga/chapters/${manga.url.substringAfterLast('/')}",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
@ -178,7 +179,7 @@ abstract class FansubsCat(
|
|||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/pages/${chapter.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
"$apiBaseUrl/manga/pages/${chapter.url.substringAfterLast('/')}",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 6
|
|
@ -1,251 +0,0 @@
|
|||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
abstract class FlixScans(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
protected val apiUrl: String = "$baseUrl/api/v1",
|
||||
protected val cdnUrl: String = baseUrl.replace("://", "://media.").plus("/"),
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
protected open val json: Json by injectLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiUrl/webtoon/pages/home/romance", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<HomeDto>()
|
||||
|
||||
val entries = (result.hot + result.topAll + result.topMonth + result.topWeek)
|
||||
.distinctBy { it.id }
|
||||
.map { it.toSManga(cdnUrl) }
|
||||
|
||||
return MangasPage(entries, false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiUrl/search/advance?page=$page&serie_type=webtoon", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<ApiResponse<BrowseSeries>>()
|
||||
|
||||
val entries = result.data.map { it.toSManga(cdnUrl) }
|
||||
val hasNextPage = result.lastPage > result.currentPage
|
||||
|
||||
return MangasPage(entries, hasNextPage)
|
||||
}
|
||||
|
||||
private var fetchGenreList: List<GenreHolder> = emptyList()
|
||||
private var fetchGenreCallOngoing = false
|
||||
private var fetchGenreFailed = false
|
||||
private var fetchGenreAttempt = 0
|
||||
|
||||
private fun fetchGenre() {
|
||||
if (fetchGenreAttempt < 3 && (fetchGenreList.isEmpty() || fetchGenreFailed) && !fetchGenreCallOngoing) {
|
||||
fetchGenreCallOngoing = true
|
||||
|
||||
// fetch genre asynchronously as it sometimes hangs
|
||||
client.newCall(fetchGenreRequest()).enqueue(fetchGenreCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private val fetchGenreCallback = object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
fetchGenreAttempt++
|
||||
fetchGenreFailed = true
|
||||
fetchGenreCallOngoing = false
|
||||
|
||||
e.message?.let { Log.e("$name Filters", it) }
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
fetchGenreCallOngoing = false
|
||||
fetchGenreAttempt++
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
fetchGenreFailed = true
|
||||
response.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val parsed = runCatching {
|
||||
response.use(::fetchGenreParse)
|
||||
}
|
||||
|
||||
fetchGenreFailed = parsed.isFailure
|
||||
fetchGenreList = parsed.getOrElse {
|
||||
Log.e("$name Filters", it.stackTraceToString())
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchGenreRequest(): Request {
|
||||
return GET("$apiUrl/search/genres", headers)
|
||||
}
|
||||
|
||||
private fun fetchGenreParse(response: Response): List<GenreHolder> {
|
||||
return response.parseAs<List<GenreHolder>>()
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
fetchGenre()
|
||||
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
Filter.Header("Ignored when using Text Search"),
|
||||
MainGenreFilter(),
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
filters += if (fetchGenreList.isNotEmpty()) {
|
||||
listOf(
|
||||
GenreFilter("Genre", fetchGenreList),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'reset' to attempt to show Genres"),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotEmpty()) {
|
||||
val url = "$apiUrl/search/serie".toHttpUrl().newBuilder()
|
||||
.addPathSegment(query.trim())
|
||||
.addQueryParameter("page", page.toString())
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
val advSearchUrl = apiUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("search/advance")
|
||||
addQueryParameter("page", page.toString())
|
||||
addQueryParameter("serie_type", "webtoon")
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
filter.checked.let {
|
||||
if (it.isNotEmpty()) {
|
||||
addQueryParameter("genres", it.joinToString(","))
|
||||
}
|
||||
}
|
||||
}
|
||||
is MainGenreFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("main_genres", filter.selected)
|
||||
}
|
||||
}
|
||||
is TypeFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("type", filter.selected)
|
||||
}
|
||||
}
|
||||
is StatusFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("status", filter.selected)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(advSearchUrl, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val (prefix, id) = getPrefixIdFromUrl(manga.url)
|
||||
|
||||
return GET("$apiUrl/webtoon/series/$id/$prefix", headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val result = response.parseAs<SeriesResponse>()
|
||||
|
||||
return result.serie.toSManga(cdnUrl)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val (prefix, id) = getPrefixIdFromUrl(manga.url)
|
||||
|
||||
return GET("$apiUrl/webtoon/chapters/$id-desc#$prefix", headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val chapters = response.parseAs<List<Chapter>>()
|
||||
val prefix = response.request.url.fragment!!
|
||||
|
||||
return chapters.map { it.toSChapter(prefix) }
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val (prefix, id) = getPrefixIdFromUrl(chapter.url)
|
||||
|
||||
return GET("$apiUrl/webtoon/chapters/chapter/$id/$prefix", headers)
|
||||
}
|
||||
|
||||
protected fun getPrefixIdFromUrl(url: String): Pair<String, String> {
|
||||
return with(url.substringAfterLast("/")) {
|
||||
val split = split("-")
|
||||
|
||||
split[0] to split[1]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<PageListResponse>()
|
||||
|
||||
return result.chapter.chapterData.webtoon.mapIndexed { i, img ->
|
||||
Page(i, "", cdnUrl + img)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): T =
|
||||
use { body.string() }.let(json::decodeFromString)
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val data: List<T>,
|
||||
@SerialName("current_page") val currentPage: Int,
|
||||
@SerialName("last_page") val lastPage: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HomeDto(
|
||||
val hot: List<BrowseSeries>,
|
||||
val topWeek: List<BrowseSeries>,
|
||||
val topMonth: List<BrowseSeries>,
|
||||
val topAll: List<BrowseSeries>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BrowseSeries(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val prefix: Int,
|
||||
val thumbnail: String?,
|
||||
) {
|
||||
fun toSManga(cdnUrl: String) = SManga.create().apply {
|
||||
title = this@BrowseSeries.title
|
||||
url = "/series/$prefix-$id-$slug"
|
||||
thumbnail_url = thumbnail?.let { cdnUrl + it }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SearchInput(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenreHolder(
|
||||
val name: String,
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriesResponse(
|
||||
val serie: Series,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Series(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val prefix: Int,
|
||||
val thumbnail: String?,
|
||||
val story: String?,
|
||||
val serieType: String?,
|
||||
val mainGenres: String?,
|
||||
val otherNames: List<String>? = emptyList(),
|
||||
val status: String?,
|
||||
val type: String?,
|
||||
val authors: List<GenreHolder>? = emptyList(),
|
||||
val artists: List<GenreHolder>? = emptyList(),
|
||||
val genres: List<GenreHolder>? = emptyList(),
|
||||
) {
|
||||
fun toSManga(cdnUrl: String) = SManga.create().apply {
|
||||
title = this@Series.title
|
||||
url = "/series/$prefix-$id-$slug"
|
||||
thumbnail_url = cdnUrl + thumbnail
|
||||
author = authors?.joinToString { it.name.trim() }
|
||||
artist = artists?.joinToString { it.name.trim() }
|
||||
genre = (otherGenres + genres?.map { it.name.trim() }.orEmpty())
|
||||
.distinct().joinToString { it.trim() }
|
||||
description = story?.let { Jsoup.parse(it).text() }
|
||||
if (otherNames?.isNotEmpty() == true) {
|
||||
if (description.isNullOrEmpty()) {
|
||||
description = "Alternative Names:\n"
|
||||
} else {
|
||||
description += "\n\nAlternative Names:\n"
|
||||
}
|
||||
description += otherNames.joinToString("\n") { "• ${it.trim()}" }
|
||||
}
|
||||
status = when (this@Series.status?.trim()) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
"onhold" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private val otherGenres = listOfNotNull(serieType, mainGenres, type)
|
||||
.map { word ->
|
||||
word.trim().replaceFirstChar {
|
||||
if (it.isLowerCase()) {
|
||||
it.titlecase(Locale.getDefault())
|
||||
} else {
|
||||
it.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val createdAt: String? = null,
|
||||
) {
|
||||
fun toSChapter(prefix: String) = SChapter.create().apply {
|
||||
url = "/read/webtoon/$prefix-$id-$slug"
|
||||
name = this@Chapter.name
|
||||
date_upload = runCatching { dateFormat.parse(createdAt!!)!!.time }.getOrDefault(0L)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PageListResponse(
|
||||
val chapter: ChapterPages,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPages(
|
||||
val chapterData: ChapterPageData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPageData(
|
||||
val webtoon: List<String>,
|
||||
)
|
|
@ -1,62 +0,0 @@
|
|||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
abstract class SelectFilter(
|
||||
name: String,
|
||||
private val options: List<String>,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
options.toTypedArray(),
|
||||
) {
|
||||
val selected get() = options[state]
|
||||
}
|
||||
|
||||
class CheckBoxFilter(
|
||||
name: String,
|
||||
val id: String,
|
||||
) : Filter.CheckBox(name)
|
||||
|
||||
class GenreFilter(
|
||||
name: String,
|
||||
private val genres: List<GenreHolder>,
|
||||
) : Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
genres.map { CheckBoxFilter(it.name.trim(), it.id.toString()) },
|
||||
) {
|
||||
val checked get() = state.filter { it.state }.map { it.id }
|
||||
}
|
||||
|
||||
class MainGenreFilter : SelectFilter(
|
||||
"Main Genre",
|
||||
listOf(
|
||||
"",
|
||||
"fantasy",
|
||||
"romance",
|
||||
"action",
|
||||
"drama",
|
||||
),
|
||||
)
|
||||
|
||||
class TypeFilter : SelectFilter(
|
||||
"Type",
|
||||
listOf(
|
||||
"",
|
||||
"manhwa",
|
||||
"manhua",
|
||||
"manga",
|
||||
"comic",
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : SelectFilter(
|
||||
"Status",
|
||||
listOf(
|
||||
"",
|
||||
"ongoing",
|
||||
"completed",
|
||||
"droped",
|
||||
"onhold",
|
||||
"soon",
|
||||
),
|
||||
)
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
baseVersionCode = 2
|
||||
|
|
|
@ -294,7 +294,7 @@ abstract class GalleryAdults(
|
|||
val categoryFilters = filters.filterIsInstance<CategoryFilters>().firstOrNull()
|
||||
|
||||
// Only for query string or multiple tags
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
val url = "$baseUrl/search/".toHttpUrl().newBuilder().apply {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index))
|
||||
}
|
||||
|
@ -310,7 +310,7 @@ abstract class GalleryAdults(
|
|||
addEncodedQueryParameter(intermediateSearchKey, buildQueryString(selectedGenres.map { it.name }, query))
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build())
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
protected open val advancedSearchKey = "key"
|
||||
|
@ -331,7 +331,7 @@ abstract class GalleryAdults(
|
|||
// Advanced search
|
||||
val advancedSearchFilters = filters.filterIsInstance<AdvancedTextFilter>()
|
||||
|
||||
val url = "$baseUrl/$advancedSearchUri".toHttpUrl().newBuilder().apply {
|
||||
val url = "$baseUrl/$advancedSearchUri/".toHttpUrl().newBuilder().apply {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index))
|
||||
}
|
||||
|
@ -379,7 +379,7 @@ abstract class GalleryAdults(
|
|||
addEncodedQueryParameter(advancedSearchKey, keys.joinToString("+"))
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build())
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 22
|
||||
baseVersionCode = 23
|
||||
|
|
|
@ -53,7 +53,13 @@ abstract class GroupLe(
|
|||
.contains("internal/redirect") or (response.code == 301)
|
||||
)
|
||||
) {
|
||||
throw IOException("Ссылка на мангу была изменена. Перемигрируйте мангу на тот же (или смежный с GroupLe) источник или передобавьте из Поисковика/Каталога.")
|
||||
if (originalRequest.url.toString().contains("/list?")) {
|
||||
throw IOException("Смените домен: Поисковик > Расширения > $name > ⚙\uFE0F")
|
||||
}
|
||||
throw IOException(
|
||||
"URL серии изменился. Перенесите/мигрируйте с $name " +
|
||||
"на $name (или смежный с GroupLe), чтобы список глав обновился",
|
||||
)
|
||||
}
|
||||
response
|
||||
}
|
||||
|
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 11
|
||||
baseVersionCode = 12
|
||||
|
|
|
@ -194,12 +194,14 @@ open class Kemono(
|
|||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imageUrl = page.imageUrl!!
|
||||
|
||||
if (!preferences.getBoolean(USE_LOW_RES_IMG, false)) return GET(imageUrl, headers)
|
||||
val index = imageUrl.indexOf('/', startIndex = 8) // https://
|
||||
|
||||
val index = imageUrl.indexOf('/', 8)
|
||||
val url = buildString {
|
||||
append(imageUrl, 0, index)
|
||||
append("/thumbnail")
|
||||
append(imageUrl, index, imageUrl.length)
|
||||
append("/thumbnail/data")
|
||||
append(imageUrl.substring(index))
|
||||
}
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 5
|
||||
baseVersionCode = 6
|
||||
|
|
|
@ -319,7 +319,7 @@ abstract class LectorTmo(
|
|||
return GET(chapter.url, tmoHeaders)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = mutableListOf<Page>().apply {
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
var doc = redirectToReadPage(document)
|
||||
|
||||
val currentUrl = doc.location()
|
||||
|
@ -336,21 +336,24 @@ abstract class LectorTmo(
|
|||
.build()
|
||||
doc = client.newCall(GET(newUrl, redirectHeaders)).execute().asJsoup()
|
||||
}
|
||||
val imagesScript = doc.selectFirst("script:containsData(var dirPath):containsData(var images)")
|
||||
|
||||
doc.select("div.viewer-container img:not(noscript img)").forEach {
|
||||
add(
|
||||
Page(
|
||||
size,
|
||||
doc.location(),
|
||||
it.let {
|
||||
if (it.hasAttr("data-src")) {
|
||||
it.attr("abs:data-src")
|
||||
} else {
|
||||
it.attr("abs:src")
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
imagesScript?.data()?.let {
|
||||
val dirPath = DIRPATH_REGEX.find(imagesScript.data())?.groupValues?.get(1)
|
||||
val images = IMAGES_REGEX.find(imagesScript.data())?.groupValues?.get(1)?.split(",")?.map { img ->
|
||||
img.trim().removeSurrounding("\"")
|
||||
}
|
||||
if (dirPath != null && images != null) {
|
||||
return images.mapIndexed { i, img ->
|
||||
Page(i, doc.location(), "$dirPath$img")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.select("div.viewer-container img:not(noscript img)").let {
|
||||
return it.mapIndexed { i, img ->
|
||||
Page(i, doc.location(), img.imgAttr())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -420,6 +423,13 @@ abstract class LectorTmo(
|
|||
return document
|
||||
}
|
||||
|
||||
private fun Element.imgAttr(): String {
|
||||
return when {
|
||||
this.hasAttr("data-src") -> this.attr("abs:data-src")
|
||||
else -> this.attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.unescapeUrl(): String {
|
||||
return if (this.startsWith("http:\\/\\/") || this.startsWith("https:\\/\\/")) {
|
||||
this.replace("\\/", "/")
|
||||
|
@ -605,6 +615,9 @@ abstract class LectorTmo(
|
|||
}
|
||||
|
||||
companion object {
|
||||
val DIRPATH_REGEX = """var\s+dirPath\s*=\s*'(.*?)'\s*;""".toRegex()
|
||||
val IMAGES_REGEX = """var\s+images\s*=.*\[(.*?)\]\s*'\s*\)\s*;""".toRegex()
|
||||
|
||||
private const val SCANLATOR_PREF = "scanlatorPref"
|
||||
private const val SCANLATOR_PREF_TITLE = "Mostrar todos los scanlator"
|
||||
private const val SCANLATOR_PREF_SUMMARY = "Se mostraran capítulos repetidos pero con diferentes Scanlators"
|
||||
|
|
|
@ -15,9 +15,9 @@
|
|||
|
||||
<!-- LibUrlActivity sites can be added here. -->
|
||||
<data
|
||||
android:host="v2.slashlib.me"
|
||||
android:pathPattern="/..*/v..*"
|
||||
android:scheme="https" />
|
||||
android:host="${SOURCEHOST}"
|
||||
android:pathPattern="/ru/manga/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 25
|
||||
baseVersionCode = 28
|
||||
|
|
|
@ -0,0 +1,295 @@
|
|||
package eu.kanade.tachiyomi.multisrc.libgroup
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class Data<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Constants(
|
||||
@SerialName("ageRestriction") val ageRestrictions: List<IdLabelSiteType>,
|
||||
@SerialName("format") val formats: List<IdNameSiteType>,
|
||||
val genres: List<IdNameSiteType>,
|
||||
val imageServers: List<ImageServer>,
|
||||
@SerialName("scanlateStatus") val scanlateStatuses: List<IdLabelSiteType>,
|
||||
@SerialName("status") val titleStatuses: List<IdLabelSiteType>,
|
||||
val tags: List<IdNameSiteType>,
|
||||
val types: List<IdLabelSiteType>,
|
||||
) {
|
||||
@Serializable
|
||||
class IdLabelSiteType(
|
||||
val id: Int,
|
||||
val label: String,
|
||||
@SerialName("site_ids") val siteIds: List<Int>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class IdNameSiteType(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@SerialName("site_ids") val siteIds: List<Int>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ImageServer(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val url: String,
|
||||
@SerialName("site_ids") val siteIds: List<Int>,
|
||||
)
|
||||
|
||||
fun getServer(isServers: String?, siteId: Int): ImageServer =
|
||||
if (!isServers.isNullOrBlank()) {
|
||||
imageServers.first { it.id == isServers && it.siteIds.contains(siteId) }
|
||||
} else {
|
||||
imageServers.first { it.siteIds.contains(siteId) }
|
||||
}
|
||||
|
||||
fun getCategories(siteId: Int): List<IdLabelSiteType> = types.filter { it.siteIds.contains(siteId) }
|
||||
fun getFormats(siteId: Int): List<IdNameSiteType> = formats.filter { it.siteIds.contains(siteId) }
|
||||
fun getGenres(siteId: Int): List<IdNameSiteType> = genres.filter { it.siteIds.contains(siteId) }
|
||||
fun getTags(siteId: Int): List<IdNameSiteType> = tags.filter { it.siteIds.contains(siteId) }
|
||||
fun getScanlateStatuses(siteId: Int): List<IdLabelSiteType> = scanlateStatuses.filter { it.siteIds.contains(siteId) }
|
||||
fun getTitleStatuses(siteId: Int): List<IdLabelSiteType> = titleStatuses.filter { it.siteIds.contains(siteId) }
|
||||
fun getAgeRestrictions(siteId: Int): List<IdLabelSiteType> = ageRestrictions.filter { it.siteIds.contains(siteId) }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MangasPageDto(
|
||||
val data: List<MangaShort>,
|
||||
val meta: MangaPageMeta,
|
||||
) {
|
||||
@Serializable
|
||||
class MangaPageMeta(
|
||||
@SerialName("has_next_page") val hasNextPage: Boolean,
|
||||
)
|
||||
|
||||
fun mapToSManga(isEng: String): List<SManga> {
|
||||
return this.data.map { it.toSManga(isEng) }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MangaShort(
|
||||
val name: String,
|
||||
@SerialName("rus_name") val rusName: String?,
|
||||
@SerialName("eng_name") val engName: String?,
|
||||
@SerialName("slug_url") val slugUrl: String,
|
||||
val cover: Cover,
|
||||
) {
|
||||
@Serializable
|
||||
data class Cover(
|
||||
val default: String?,
|
||||
)
|
||||
|
||||
fun toSManga(isEng: String) = SManga.create().apply {
|
||||
title = getSelectedLanguage(isEng, rusName, engName, name)
|
||||
thumbnail_url = cover.default.orEmpty()
|
||||
url = "/$slugUrl"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Manga(
|
||||
val type: LabelType,
|
||||
val ageRestriction: LabelType,
|
||||
val rating: Rating,
|
||||
val genres: List<NameType>,
|
||||
val tags: List<NameType>,
|
||||
@SerialName("rus_name") val rusName: String?,
|
||||
@SerialName("eng_name") val engName: String?,
|
||||
val name: String,
|
||||
val cover: MangaShort.Cover,
|
||||
val authors: List<NameType>,
|
||||
val artists: List<NameType>,
|
||||
val status: LabelType,
|
||||
val scanlateStatus: LabelType,
|
||||
@SerialName("is_licensed") val isLicensed: Boolean,
|
||||
val otherNames: List<String>,
|
||||
val summary: String,
|
||||
) {
|
||||
@Serializable
|
||||
class LabelType(
|
||||
val label: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class NameType(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Rating(
|
||||
val average: Float,
|
||||
val votes: Int,
|
||||
)
|
||||
|
||||
fun toSManga(isEng: String): SManga = SManga.create().apply {
|
||||
title = getSelectedLanguage(isEng, rusName, engName, name)
|
||||
thumbnail_url = cover.default
|
||||
author = authors.joinToString { it.name }
|
||||
artist = artists.joinToString { it.name }
|
||||
status = parseStatus(isLicensed, scanlateStatus.label, this@Manga.status.label)
|
||||
genre = type.label.ifBlank { "Манга" } + ", " + ageRestriction.label + ", " +
|
||||
genres.joinToString { it.name.trim() } + ", " + tags.joinToString { it.name.trim() }
|
||||
description = getOppositeLanguage(isEng, rusName, engName) + rating.average.parseAverage() + " " + rating.average +
|
||||
" (голосов: " + rating.votes + ")\n" + otherNames.joinAltNames() + summary
|
||||
}
|
||||
|
||||
private fun Float.parseAverage(): String {
|
||||
return when {
|
||||
this > 9.5 -> "★★★★★"
|
||||
this > 8.5 -> "★★★★✬"
|
||||
this > 7.5 -> "★★★★☆"
|
||||
this > 6.5 -> "★★★✬☆"
|
||||
this > 5.5 -> "★★★☆☆"
|
||||
this > 4.5 -> "★★✬☆☆"
|
||||
this > 3.5 -> "★★☆☆☆"
|
||||
this > 2.5 -> "★✬☆☆☆"
|
||||
this > 1.5 -> "★☆☆☆☆"
|
||||
this > 0.5 -> "✬☆☆☆☆"
|
||||
else -> "☆☆☆☆☆"
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStatus(isLicensed: Boolean, statusTranslate: String, statusTitle: String): Int = when {
|
||||
isLicensed -> SManga.LICENSED
|
||||
statusTranslate == "Завершён" && statusTitle == "Приостановлен" || statusTranslate == "Заморожен" || statusTranslate == "Заброшен" -> SManga.ON_HIATUS
|
||||
statusTranslate == "Завершён" && statusTitle == "Выпуск прекращён" -> SManga.CANCELLED
|
||||
statusTranslate == "Продолжается" -> SManga.ONGOING
|
||||
statusTranslate == "Выходит" -> SManga.ONGOING
|
||||
statusTranslate == "Завершён" -> SManga.COMPLETED
|
||||
statusTranslate == "Вышло" -> SManga.PUBLISHING_FINISHED
|
||||
else -> when (statusTitle) {
|
||||
"Онгоинг" -> SManga.ONGOING
|
||||
"Анонс" -> SManga.ONGOING
|
||||
"Завершён" -> SManga.COMPLETED
|
||||
"Приостановлен" -> SManga.ON_HIATUS
|
||||
"Выпуск прекращён" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.joinAltNames(): String = when {
|
||||
this.isNotEmpty() -> "Альтернативные названия:\n" + this.joinToString(" / ") + "\n\n"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedLanguage(isEng: String, rusName: String?, engName: String?, name: String): String = when {
|
||||
isEng == "rus" && rusName.orEmpty().isNotEmpty() -> rusName!!
|
||||
isEng == "eng" && engName.orEmpty().isNotEmpty() -> engName!!
|
||||
else -> name
|
||||
}
|
||||
|
||||
private fun getOppositeLanguage(isEng: String, rusName: String?, engName: String?): String = when {
|
||||
isEng == "eng" && rusName.orEmpty().isNotEmpty() -> rusName + "\n"
|
||||
isEng == "rus" && engName.orEmpty().isNotEmpty() -> engName + "\n"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Chapter(
|
||||
val id: Int,
|
||||
@SerialName("branches_count") val branchesCount: Int,
|
||||
val branches: List<Branch>,
|
||||
val name: String?,
|
||||
val number: String,
|
||||
val volume: String,
|
||||
@SerialName("item_number") val itemNumber: Float?,
|
||||
) {
|
||||
@Serializable
|
||||
class Branch(
|
||||
@SerialName("branch_id") val branchId: Int?,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
val teams: List<Team>,
|
||||
val user: User,
|
||||
) {
|
||||
@Serializable
|
||||
class Team(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class User(
|
||||
val username: String,
|
||||
)
|
||||
}
|
||||
|
||||
private fun first(branchId: Int? = null): Branch? {
|
||||
return runCatching { if (branchId != null) branches.first { it.branchId == branchId } else branches.first() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun getTeamName(branchId: Int? = null): String? {
|
||||
return runCatching { first(branchId)!!.teams.first().name }.getOrNull()
|
||||
}
|
||||
|
||||
private fun getUserName(branchId: Int? = null): String? {
|
||||
return runCatching { first(branchId)!!.user.username }.getOrNull()
|
||||
}
|
||||
|
||||
fun toSChapter(slugUrl: String, branchId: Int? = null, isScanUser: Boolean): SChapter = SChapter.create().apply {
|
||||
val chapterName = "Том $volume. Глава $number"
|
||||
name = if (this@Chapter.name.isNullOrBlank()) chapterName else "$chapterName - ${this@Chapter.name}"
|
||||
val branchStr = if (branchId != null) "&branch_id=$branchId" else ""
|
||||
url = "/$slugUrl/chapter?$branchStr&volume=$volume&number=$number"
|
||||
scanlator = getTeamName(branchId) ?: if (isScanUser) getUserName(branchId) else null
|
||||
date_upload = runCatching { LibGroup.simpleDateFormat.parse(first(branchId)!!.createdAt)!!.time }.getOrDefault(0L)
|
||||
chapter_number = itemNumber ?: -1f
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Chapter>.getBranchCount(): Int = this.maxOf { chapter -> chapter.branches.size }
|
||||
|
||||
@Serializable
|
||||
class Branch(
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Pages(
|
||||
val pages: List<MangaPage>,
|
||||
) {
|
||||
@Serializable
|
||||
class MangaPage(
|
||||
val slug: Int,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
fun toPageList(): List<Page> = pages.map { Page(it.slug, it.url) }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AuthToken(
|
||||
private val auth: Auth,
|
||||
private val token: Token,
|
||||
) {
|
||||
@Serializable
|
||||
class Auth(
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Token(
|
||||
val timestamp: Long,
|
||||
@SerialName("expires_in") val expiresIn: Long,
|
||||
@SerialName("token_type") val tokenType: String,
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
)
|
||||
|
||||
fun isExpired(): Boolean {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val expiresIn = token.timestamp + (token.expiresIn * 1000)
|
||||
return expiresIn < currentTime
|
||||
}
|
||||
|
||||
fun getToken(): String = "${token.tokenType} ${token.accessToken}"
|
||||
|
||||
fun getUserId(): Int = auth.id
|
||||
}
|
|
@ -19,7 +19,7 @@ class LibUrlActivity : Activity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 0) {
|
||||
val titleid = pathSegments[0]
|
||||
val titleid = pathSegments[2]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${LibGroup.PREFIX_SLUG_SEARCH}$titleid")
|
||||
|
|
|
@ -615,6 +615,7 @@ abstract class Madara(
|
|||
"Đã hoàn thành",
|
||||
"Завершено",
|
||||
"Tamamlanan",
|
||||
"Complété",
|
||||
)
|
||||
|
||||
protected val ongoingStatusList: Array<String> = arrayOf(
|
||||
|
@ -622,7 +623,7 @@ abstract class Madara(
|
|||
"Em Andamento", "En cours", "En Cours", "En cours de publication", "Ativo", "Lançando", "Đang Tiến Hành", "Devam Ediyor",
|
||||
"Devam ediyor", "In Corso", "In Arrivo", "مستمرة", "مستمر", "En Curso", "En curso", "Emision",
|
||||
"Curso", "En marcha", "Publicandose", "En emision", "连载中", "Em Lançamento", "Devam Ediyo",
|
||||
"Đang làm", "Em postagem", "Devam Eden", "Em progresso",
|
||||
"Đang làm", "Em postagem", "Devam Eden", "Em progresso", "Em curso",
|
||||
)
|
||||
|
||||
protected val hiatusStatusList: Array<String> = arrayOf(
|
||||
|
@ -635,6 +636,7 @@ abstract class Madara(
|
|||
"متوقف",
|
||||
"En Pause",
|
||||
"Заморожено",
|
||||
"En attente",
|
||||
)
|
||||
|
||||
protected val canceledStatusList: Array<String> = arrayOf(
|
||||
|
@ -646,6 +648,7 @@ abstract class Madara(
|
|||
"ملغي",
|
||||
"Abandonné",
|
||||
"Заброшено",
|
||||
"Annulé",
|
||||
)
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
|
|
|
@ -208,7 +208,7 @@ constructor(
|
|||
|
||||
override fun searchMangaNextPageSelector(): String? = ".pagination a[rel=next]"
|
||||
|
||||
protected fun parseSearchDirectory(page: Int): MangasPage {
|
||||
protected open fun parseSearchDirectory(page: Int): MangasPage {
|
||||
val manga = searchDirectory.subList((page - 1) * 24, min(page * 24, searchDirectory.size))
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
|
|
|
@ -2,4 +2,4 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 2
|
||||
baseVersionCode = 3
|
||||
|
|
|
@ -36,12 +36,12 @@ abstract class Senkuro(
|
|||
override val supportsLatest = false
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("User-Agent", "Tachiyomi (+https://github.com/tachiyomiorg/tachiyomi)")
|
||||
.add("User-Agent", "Tachiyomi (+https://github.com/keiyoushi/extensions-source)")
|
||||
.add("Content-Type", "application/json")
|
||||
|
||||
override val client: OkHttpClient =
|
||||
network.client.newBuilder()
|
||||
.rateLimit(5)
|
||||
.rateLimit(3)
|
||||
.build()
|
||||
|
||||
private inline fun <reified T : Any> T.toJsonRequestBody(): RequestBody =
|
||||
|
|
|
@ -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.multisrc.terrascan.TerraScanUrlActivity"
|
||||
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="${SOURCEHOST}"
|
||||
android:pathPattern="/manga/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
|
@ -0,0 +1,245 @@
|
|||
package eu.kanade.tachiyomi.multisrc.terrascan
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class TerraScan(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd-MM-yyyy", Locale("pt", "BR")),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest: Boolean = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
private val noRedirectClient = network.cloudflareClient.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private var genresList: List<Genre> = emptyList()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?q=p&page=$page", headers)
|
||||
|
||||
open val popularMangaTitleSelector: String = "p, h3"
|
||||
open val popularMangaThumbnailSelector: String = "img"
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
title = element.selectFirst(popularMangaTitleSelector)!!.ownText()
|
||||
thumbnail_url = element.selectFirst(popularMangaThumbnailSelector)?.srcAttr()
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = ".pagination > .page-item:not(.disabled):last-child"
|
||||
|
||||
override fun popularMangaSelector(): String = ".series-paginated .grid-item-series, .series-paginated .series"
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (genresList.isEmpty()) {
|
||||
genresList = parseGenres(document)
|
||||
}
|
||||
val mangas = document.select(popularMangaSelector())
|
||||
.map(::popularMangaFromElement)
|
||||
|
||||
return MangasPage(mangas, document.selectFirst(popularMangaNextPageSelector()) != null)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga?q=u&page=$page", headers)
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(URL_SEARCH_PREFIX)) {
|
||||
val slug = query.substringAfter(URL_SEARCH_PREFIX)
|
||||
return client.newCall(GET("$baseUrl/manga/$slug", headers))
|
||||
.asObservableSuccess().map { response ->
|
||||
MangasPage(listOf(mangaDetailsParse(response)), false)
|
||||
}
|
||||
}
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder()
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
url.addPathSegment("search")
|
||||
.addQueryParameter("q", query)
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
url.addPathSegment("manga")
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
filter.state.forEach {
|
||||
if (it.state) {
|
||||
url.addQueryParameter(it.query, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
url.addQueryParameter("page", "$page")
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun searchMangaSelector() = ".col-6.col-sm-3.col-md-3.col-lg-2.p-1"
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (response.request.url.pathSegments.contains("search")) {
|
||||
return searchByQueryMangaParse(response)
|
||||
}
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters = mutableListOf<Filter<out Any>>()
|
||||
if (genresList.isNotEmpty()) {
|
||||
filters += GenreFilter(
|
||||
title = "Gêneros",
|
||||
genres = genresList,
|
||||
)
|
||||
} else {
|
||||
filters += Filter.Header("Aperte 'Redefinir' mostrar os gêneros disponíveis")
|
||||
}
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
open val mangaDetailsContainerSelector: String = "main"
|
||||
open val mangaDetailsTitleSelector: String = "h1"
|
||||
open val mangaDetailsThumbnailSelector: String = "img"
|
||||
open val mangaDetailsDescriptionSelector: String = "p"
|
||||
open val mangaDetailsGenreSelector: String = ".card:has(h5:contains(Categorias)) a, .card:has(h5:contains(Categorias)) div"
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
with(document.selectFirst(mangaDetailsContainerSelector)!!) {
|
||||
title = selectFirst(mangaDetailsTitleSelector)!!.text()
|
||||
thumbnail_url = selectFirst(mangaDetailsThumbnailSelector)?.absUrl("href")
|
||||
description = selectFirst(mangaDetailsDescriptionSelector)?.text()
|
||||
genre = document.select(mangaDetailsGenreSelector)
|
||||
.joinToString { it.ownText() }
|
||||
}
|
||||
setUrlWithoutDomain(document.location())
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
with(element.selectFirst("h5")!!) {
|
||||
name = ownText()
|
||||
date_upload = selectFirst("div")!!.ownText().toDate()
|
||||
}
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".col-chapter a"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val mangaChapterUrl = document.location()
|
||||
val maxPage = findPageCount(mangaChapterUrl)
|
||||
return (1..maxPage).map { page -> Page(page - 1, "$mangaChapterUrl/$page") }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = document.selectFirst("main img")!!.srcAttr()
|
||||
|
||||
private fun searchByQueryMangaParse(response: Response): MangasPage {
|
||||
val fragment = Jsoup.parseBodyFragment(
|
||||
json.decodeFromString<String>(response.body.string()),
|
||||
baseUrl,
|
||||
)
|
||||
|
||||
return MangasPage(
|
||||
mangas = fragment.select(searchMangaSelector()).map(::searchMangaFromElement),
|
||||
hasNextPage = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findPageCount(pageUrl: String): Int {
|
||||
var lowerBound = 1
|
||||
var upperBound = 100
|
||||
|
||||
while (lowerBound <= upperBound) {
|
||||
val midpoint = lowerBound + (upperBound - lowerBound) / 2
|
||||
|
||||
val request = Request.Builder().apply {
|
||||
url("$pageUrl/$midpoint")
|
||||
headers(headers)
|
||||
head()
|
||||
}.build()
|
||||
|
||||
val response = try {
|
||||
noRedirectClient.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to fetch $pageUrl")
|
||||
}
|
||||
|
||||
if (response.code == 302) {
|
||||
upperBound = midpoint - 1
|
||||
} else {
|
||||
lowerBound = midpoint + 1
|
||||
}
|
||||
}
|
||||
|
||||
return lowerBound
|
||||
}
|
||||
|
||||
private fun Element.srcAttr(): String = when {
|
||||
hasAttr("data-src") -> absUrl("data-src")
|
||||
else -> absUrl("src")
|
||||
}
|
||||
|
||||
private fun String.toDate() = try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L }
|
||||
|
||||
open val genreFilterSelector: String = "form div > div:has(input) div"
|
||||
|
||||
private fun parseGenres(document: Document): List<Genre> {
|
||||
return document.select(genreFilterSelector)
|
||||
.map { element ->
|
||||
val input = element.selectFirst("input")!!
|
||||
Genre(
|
||||
name = element.selectFirst("label")!!.ownText(),
|
||||
query = input.attr("name"),
|
||||
value = input.attr("value"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val URL_SEARCH_PREFIX = "slug:"
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package eu.kanade.tachiyomi.extension.pt.mangaterra
|
||||
package eu.kanade.tachiyomi.multisrc.terrascan
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package eu.kanade.tachiyomi.extension.pt.mangaterra
|
||||
package eu.kanade.tachiyomi.multisrc.terrascan
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
|
@ -7,7 +7,7 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MangaTerraUrlActivity : Activity() {
|
||||
class TerraScanUrlActivity : Activity() {
|
||||
|
||||
private val tag = javaClass.simpleName
|
||||
|
||||
|
@ -35,5 +35,5 @@ class MangaTerraUrlActivity : Activity() {
|
|||
}
|
||||
|
||||
private fun slug(pathSegments: List<String>) =
|
||||
"${MangaTerra.PREFIX_SEARCH}${pathSegments[pathSegments.size - 1]}"
|
||||
"${TerraScan.URL_SEARCH_PREFIX}${pathSegments[pathSegments.size - 1]}"
|
||||
}
|
|
@ -2,7 +2,7 @@ plugins {
|
|||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 5
|
||||
baseVersionCode = 6
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:i18n"))
|
||||
|
|
|
@ -163,6 +163,8 @@ abstract class WPComics(
|
|||
val minuteWords = listOf("minute", "phút")
|
||||
val hourWords = listOf("hour", "giờ")
|
||||
val dayWords = listOf("day", "ngày")
|
||||
val monthWords = listOf("month", "tháng")
|
||||
val yearWords = listOf("year", "năm")
|
||||
val agoWords = listOf("ago", "trước")
|
||||
|
||||
return try {
|
||||
|
@ -171,6 +173,8 @@ abstract class WPComics(
|
|||
val calendar = Calendar.getInstance()
|
||||
|
||||
when {
|
||||
yearWords.doesInclude(trimmedDate[1]) -> calendar.apply { add(Calendar.YEAR, -trimmedDate[0].toInt()) }
|
||||
monthWords.doesInclude(trimmedDate[1]) -> calendar.apply { add(Calendar.MONTH, -trimmedDate[0].toInt()) }
|
||||
dayWords.doesInclude(trimmedDate[1]) -> calendar.apply { add(Calendar.DAY_OF_MONTH, -trimmedDate[0].toInt()) }
|
||||
hourWords.doesInclude(trimmedDate[1]) -> calendar.apply { add(Calendar.HOUR_OF_DAY, -trimmedDate[0].toInt()) }
|
||||
minuteWords.doesInclude(trimmedDate[1]) -> calendar.apply { add(Calendar.MINUTE, -trimmedDate[0].toInt()) }
|
||||
|
|
|
@ -8,9 +8,9 @@ import androidx.preference.PreferenceScreen
|
|||
import okhttp3.Headers
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to return UserAgentType based on SharedPreference value
|
||||
*/
|
||||
/**
|
||||
* Helper function to return UserAgentType based on SharedPreference value
|
||||
*/
|
||||
fun SharedPreferences.getPrefUAType(): UserAgentType {
|
||||
return when (getString(PREF_KEY_RANDOM_UA, "off")) {
|
||||
"mobile" -> UserAgentType.MOBILE
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Akuma'
|
||||
extClass = '.Akuma'
|
||||
extVersionCode = 1
|
||||
extClass = '.AkumaFactory'
|
||||
extVersionCode = 4
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -21,15 +21,20 @@ import org.jsoup.nodes.Document
|
|||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.io.IOException
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class Akuma : ParsedHttpSource() {
|
||||
class Akuma(
|
||||
override val lang: String,
|
||||
private val akumaLang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val name = "Akuma"
|
||||
|
||||
override val baseUrl = "https://akuma.moe"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
private var nextHash: String? = null
|
||||
|
@ -38,6 +43,9 @@ class Akuma : ParsedHttpSource() {
|
|||
|
||||
private val ddosGuardIntercept = DDosGuardInterceptor(network.client)
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
override val client: OkHttpClient = network.client.newBuilder()
|
||||
.addInterceptor(ddosGuardIntercept)
|
||||
.addInterceptor(::tokenInterceptor)
|
||||
|
@ -102,12 +110,19 @@ class Akuma : ParsedHttpSource() {
|
|||
.add("view", "3")
|
||||
.build()
|
||||
|
||||
return if (page == 1) {
|
||||
val url = baseUrl.toHttpUrlOrNull()!!.newBuilder()
|
||||
|
||||
if (page == 1) {
|
||||
nextHash = null
|
||||
POST(baseUrl, headers, payload)
|
||||
} else {
|
||||
POST("$baseUrl/?cursor=$nextHash", headers, payload)
|
||||
url.addQueryParameter("cursor", nextHash)
|
||||
}
|
||||
if (lang != "all") {
|
||||
// append like `q=language:english$`
|
||||
url.addQueryParameter("q", "language:$akumaLang$")
|
||||
}
|
||||
|
||||
return POST(url.toString(), headers, payload)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = ".post-loop li"
|
||||
|
@ -116,6 +131,10 @@ class Akuma : ParsedHttpSource() {
|
|||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
if (document.text().contains("Max keywords of 3 exceeded.")) {
|
||||
throw Exception("Login required for more than 3 filters")
|
||||
} else if (document.text().contains("Max keywords of 8 exceeded.")) throw Exception("Only max of 8 filters are allowed")
|
||||
|
||||
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||
popularMangaFromElement(element)
|
||||
}
|
||||
|
@ -154,8 +173,39 @@ class Akuma : ParsedHttpSource() {
|
|||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val request = popularMangaRequest(page)
|
||||
|
||||
val finalQuery: MutableList<String> = mutableListOf(query)
|
||||
|
||||
if (lang != "all") {
|
||||
finalQuery.add("language: $akumaLang$")
|
||||
}
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is TextFilter -> {
|
||||
if (filter.state.isNotEmpty()) {
|
||||
finalQuery.addAll(
|
||||
filter.state.split(",").filter { it.isNotBlank() }.map {
|
||||
(if (it.trim().startsWith("-")) "-" else "") + "${filter.tag}:\"${it.trim().replace("-", "")}\""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is OptionFilter -> {
|
||||
if (filter.state > 0) finalQuery.add("opt:${filter.getValue()}")
|
||||
}
|
||||
is CategoryFilter -> {
|
||||
filter.state.forEach {
|
||||
when {
|
||||
it.isIncluded() -> finalQuery.add("category:\"${it.name}\"")
|
||||
it.isExcluded() -> finalQuery.add("-category:\"${it.name}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val url = request.url.newBuilder()
|
||||
.addQueryParameter("q", query.trim())
|
||||
.setQueryParameter("q", finalQuery.joinToString(" "))
|
||||
.build()
|
||||
|
||||
return request.newBuilder()
|
||||
|
@ -168,24 +218,62 @@ class Akuma : ParsedHttpSource() {
|
|||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.select(".entry-title").text()
|
||||
thumbnail_url = document.select(".img-thumbnail").attr("abs:src")
|
||||
author = document.select("li.meta-data > span.artist + span.value").text()
|
||||
genre = document.select(".info-list a").joinToString { it.text() }
|
||||
description = document.select(".pages span.value").text() + " Pages"
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.COMPLETED
|
||||
override fun mangaDetailsParse(document: Document) = with(document) {
|
||||
SManga.create().apply {
|
||||
title = select(".entry-title").text()
|
||||
thumbnail_url = select(".img-thumbnail").attr("abs:src")
|
||||
|
||||
author = select(".group~.value").eachText().joinToString()
|
||||
artist = select(".artist~.value").eachText().joinToString()
|
||||
|
||||
val characters = select(".character~.value").eachText()
|
||||
val parodies = select(".parody~.value").eachText()
|
||||
val males = select(".male~.value")
|
||||
.map { "${it.text()} ♂" }
|
||||
val females = select(".female~.value")
|
||||
.map { "${it.text()} ♀" }
|
||||
val others = select(".other~.value")
|
||||
.map { "${it.text()} ◊" }
|
||||
// show all in tags for quickly searching
|
||||
|
||||
genre = (males + females + others).joinToString()
|
||||
description = buildString {
|
||||
append(
|
||||
"Full English and Japanese title: \n",
|
||||
select(".entry-title").text(),
|
||||
"\n",
|
||||
select(".entry-title+span").text(),
|
||||
"\n\n",
|
||||
)
|
||||
|
||||
// translated should show up in the description
|
||||
append("Language: ", select(".language~.value").eachText().joinToString(), "\n")
|
||||
append("Pages: ", select(".pages .value").text(), "\n")
|
||||
append("Upload Date: ", select(".date .value>time").text().replace(" ", ", ") + " UTC", "\n")
|
||||
append("Categories: ", selectFirst(".info-list .value")?.text() ?: "Unknown", "\n\n")
|
||||
|
||||
// show followings for easy to reference
|
||||
parodies.takeIf { it.isNotEmpty() }?.let { append("Parodies: ", parodies.joinToString(), "\n") }
|
||||
characters.takeIf { it.isNotEmpty() }?.let { append("Characters: ", characters.joinToString(), "\n") }
|
||||
}
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.just(
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/1"
|
||||
name = "Chapter"
|
||||
},
|
||||
),
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain("${response.request.url}/1")
|
||||
name = "Chapter"
|
||||
date_upload = try {
|
||||
dateFormat.parse(document.select(".date .value>time").text())!!.time
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -201,6 +289,8 @@ class Akuma : ParsedHttpSource() {
|
|||
pageList.add(Page(i, "$url/$i"))
|
||||
}
|
||||
|
||||
pageList[0].imageUrl = imageUrlParse(document)
|
||||
|
||||
return pageList
|
||||
}
|
||||
|
||||
|
@ -208,6 +298,8 @@ class Akuma : ParsedHttpSource() {
|
|||
return document.select(".entry-content img").attr("abs:src")
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList = getFilters()
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID = "id:"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package eu.kanade.tachiyomi.extension.all.akuma
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class AkumaFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
Akuma("all", "all"),
|
||||
Akuma("en", "english"),
|
||||
Akuma("id", "indonesian"),
|
||||
Akuma("jv", "javanese"),
|
||||
Akuma("ca", "catalan"),
|
||||
Akuma("ceb", "cebuano"),
|
||||
Akuma("cs", "czech"),
|
||||
Akuma("da", "danish"),
|
||||
Akuma("de", "german"),
|
||||
Akuma("et", "estonian"),
|
||||
Akuma("es", "spanish"),
|
||||
Akuma("eo", "esperanto"),
|
||||
Akuma("fr", "french"),
|
||||
Akuma("it", "italian"),
|
||||
Akuma("hi", "hindi"),
|
||||
Akuma("hu", "hungarian"),
|
||||
Akuma("nl", "dutch"),
|
||||
Akuma("pl", "polish"),
|
||||
Akuma("pt", "portuguese"),
|
||||
Akuma("vi", "vietnamese"),
|
||||
Akuma("tr", "turkish"),
|
||||
Akuma("ru", "russian"),
|
||||
Akuma("uk", "ukrainian"),
|
||||
Akuma("ar", "arabic"),
|
||||
Akuma("ko", "korean"),
|
||||
Akuma("zh", "chinese"),
|
||||
Akuma("ja", "japanese"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package eu.kanade.tachiyomi.extension.all.akuma
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
TextFilter("Female Tags", "female"),
|
||||
TextFilter("Male Tags", "male"),
|
||||
TextFilter("Other Tags", "other"),
|
||||
CategoryFilter(),
|
||||
TextFilter("Groups", "group"),
|
||||
TextFilter("Artists", "artist"),
|
||||
TextFilter("Parody", "parody"),
|
||||
TextFilter("Characters", "character"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Search in favorites, read, or commented"),
|
||||
OptionFilter(),
|
||||
)
|
||||
}
|
||||
|
||||
internal class TextFilter(name: String, val tag: String) : Filter.Text(name)
|
||||
internal class OptionFilter(val value: List<Pair<String, String>> = options) : Filter.Select<String>("Options", options.map { it.first }.toTypedArray()) {
|
||||
fun getValue() = options[state].second
|
||||
}
|
||||
|
||||
internal open class TagTriState(name: String) : Filter.TriState(name)
|
||||
internal class CategoryFilter() :
|
||||
Filter.Group<Filter.TriState>("Categories", categoryList.map { TagTriState(it) })
|
||||
|
||||
private val categoryList = listOf(
|
||||
"Doujinshi",
|
||||
"Manga",
|
||||
"Image Set",
|
||||
"Artist CG",
|
||||
"Game CG",
|
||||
"Western",
|
||||
"Non-H",
|
||||
"Cosplay",
|
||||
"Misc",
|
||||
)
|
||||
private val options = listOf(
|
||||
"None" to "",
|
||||
"Favorited only" to "favorited",
|
||||
"Read only" to "read",
|
||||
"Commented only" to "commented",
|
||||
)
|
|
@ -18,6 +18,15 @@ class AsmHentai(
|
|||
lang = lang,
|
||||
) {
|
||||
override val supportsLatest = mangaLang.isNotBlank()
|
||||
override val supportSpeechless: Boolean = true
|
||||
|
||||
override fun Element.mangaLang() =
|
||||
select("a:has(.flag)").attr("href")
|
||||
.removeSuffix("/").substringAfterLast("/")
|
||||
.let {
|
||||
// Include Speechless in search results
|
||||
if (it == LANGUAGE_SPEECHLESS) mangaLang else it
|
||||
}
|
||||
|
||||
override fun Element.mangaUrl() =
|
||||
selectFirst(".image a")?.attr("abs:href")
|
||||
|
@ -25,10 +34,6 @@ class AsmHentai(
|
|||
override fun Element.mangaThumbnail() =
|
||||
selectFirst(".image img")?.imgAttr()
|
||||
|
||||
override fun Element.mangaLang() =
|
||||
select("a:has(.flag)").attr("href")
|
||||
.removeSuffix("/").substringAfterLast("/")
|
||||
|
||||
override fun popularMangaSelector() = ".preview_item"
|
||||
|
||||
override val favoritePath = "inc/user.php?act=favs"
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'Galaxy'
|
||||
extClass = '.GalaxyFactory'
|
||||
extVersionCode = 2
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 19 KiB |
|
@ -0,0 +1,327 @@
|
|||
package eu.kanade.tachiyomi.extension.all.galaxy
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import java.util.Calendar
|
||||
|
||||
abstract class Galaxy(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return if (page == 1) {
|
||||
GET("$baseUrl/webtoons/romance/home", headers)
|
||||
} else {
|
||||
GET("$baseUrl/webtoons/action/home", headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val entries = document.select(
|
||||
"""div.tabs div[wire:snapshot*=App\\Models\\Serie], main div:has(h2:matches(Today\'s Hot|الرائج اليوم)) a[wire:snapshot*=App\\Models\\Serie]""",
|
||||
).map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(
|
||||
if (element.tagName().equals("a")) {
|
||||
element.absUrl("href")
|
||||
} else {
|
||||
element.selectFirst("a")!!.absUrl("href")
|
||||
},
|
||||
)
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||
title = element.selectFirst("div.text-sm")!!.text()
|
||||
}
|
||||
}.distinctBy { it.url }
|
||||
|
||||
return MangasPage(entries, response.request.url.pathSegments.getOrNull(1) == "romance")
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$baseUrl/latest?serie_type=webtoon&main_genres=romance" +
|
||||
if (page > 1) {
|
||||
"&page=$page"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val entries = document.select("div[wire:snapshot*=App\\\\Models\\\\Serie]").map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||
title = element.select("div.flex a[href*=/series/]").last()!!.text()
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst("[role=navigation] button[wire:click*=nextPage]") != null
|
||||
|
||||
return MangasPage(entries, hasNextPage)
|
||||
}
|
||||
|
||||
private var filters: List<FilterData> = emptyList()
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
protected fun launchIO(block: () -> Unit) = scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
launchIO {
|
||||
if (filters.isEmpty()) {
|
||||
val document = client.newCall(GET("$baseUrl/search", headers)).execute().asJsoup()
|
||||
|
||||
val mainGenre = FilterData(
|
||||
displayName = document.select("label[for$=main_genres]").text(),
|
||||
options = document.select("select[wire:model.live=main_genres] option").map {
|
||||
it.text() to it.attr("value")
|
||||
},
|
||||
queryParameter = "main_genres",
|
||||
)
|
||||
val typeFilter = FilterData(
|
||||
displayName = document.select("label[for$=type]").text(),
|
||||
options = document.select("select[wire:model.live=type] option").map {
|
||||
it.text() to it.attr("value")
|
||||
},
|
||||
queryParameter = "type",
|
||||
)
|
||||
val statusFilter = FilterData(
|
||||
displayName = document.select("label[for$=status]").text(),
|
||||
options = document.select("select[wire:model.live=status] option").map {
|
||||
it.text() to it.attr("value")
|
||||
},
|
||||
queryParameter = "status",
|
||||
)
|
||||
val genreFilter = FilterData(
|
||||
displayName = if (lang == "ar") {
|
||||
"التصنيفات"
|
||||
} else {
|
||||
"Genre"
|
||||
},
|
||||
options = document.select("div[x-data*=genre] > div").map {
|
||||
it.text() to it.attr("wire:key")
|
||||
},
|
||||
queryParameter = "genre",
|
||||
)
|
||||
|
||||
filters = listOf(mainGenre, typeFilter, statusFilter, genreFilter)
|
||||
}
|
||||
}
|
||||
|
||||
val filters: List<Filter<*>> = filters.map {
|
||||
SelectFilter(
|
||||
it.displayName,
|
||||
it.options,
|
||||
it.queryParameter,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(
|
||||
Filter.Header("Press 'reset' to load filters"),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("serie_type", "webtoon")
|
||||
addQueryParameter("title", query.trim())
|
||||
filters.filterIsInstance<SelectFilter>().forEach {
|
||||
it.addFilterParameter(this)
|
||||
}
|
||||
if (page > 1) {
|
||||
addQueryParameter("page", page.toString())
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return SManga.create().apply {
|
||||
title = document.select("#full_model h3").text()
|
||||
thumbnail_url = document.selectFirst("main img[src*=series/webtoon]")?.absUrl("src")
|
||||
status = when (document.getQueryParam("status")) {
|
||||
"ongoing", "soon" -> SManga.ONGOING
|
||||
"completed", "droped" -> SManga.COMPLETED
|
||||
"onhold" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
genre = buildList {
|
||||
document.getQueryParam("type")
|
||||
?.capitalize()?.let(::add)
|
||||
document.select("#full_model a[href*=search?genre]")
|
||||
.eachText().let(::addAll)
|
||||
}.joinToString()
|
||||
author = document.select("#full_model [wire:key^=a-]").eachText().joinToString()
|
||||
artist = document.select("#full_model [wire:key^=r-]").eachText().joinToString()
|
||||
description = buildString {
|
||||
append(document.select("#full_model p").text().trim())
|
||||
append("\n\nAlternative Names:\n")
|
||||
document.select("#full_model [wire:key^=n-]")
|
||||
.joinToString("\n") { "• ${it.text().trim().removeMdEscaped()}" }
|
||||
.let(::append)
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Document.getQueryParam(queryParam: String): String? {
|
||||
return selectFirst("#full_model a[href*=search?$queryParam]")
|
||||
?.absUrl("href")?.toHttpUrlOrNull()?.queryParameter(queryParam)
|
||||
}
|
||||
|
||||
private fun String.capitalize(): String {
|
||||
val result = StringBuilder(length)
|
||||
var capitalize = true
|
||||
for (char in this) {
|
||||
result.append(
|
||||
if (capitalize) {
|
||||
char.uppercase()
|
||||
} else {
|
||||
char.lowercase()
|
||||
},
|
||||
)
|
||||
capitalize = char.isWhitespace()
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private val mdRegex = Regex("""&#(\d+);""")
|
||||
|
||||
private fun String.removeMdEscaped(): String {
|
||||
val char = mdRegex.find(this)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: return this
|
||||
|
||||
return replaceFirst(mdRegex, Char(char).toString())
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select("a[href*=/read/]:not([type=button])").map { element ->
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
name = element.select("span.font-normal").text()
|
||||
date_upload = element.selectFirst("div:not(:has(> svg)) > span.text-xs")
|
||||
?.text().parseRelativeDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun String?.parseRelativeDate(): Long {
|
||||
this ?: return 0L
|
||||
|
||||
val number = Regex("""(\d+)""").find(this)?.value?.toIntOrNull() ?: 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
listOf("second", "ثانية").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("دقيقتين", true) -> {
|
||||
cal.apply { add(Calendar.MINUTE, -2) }.timeInMillis
|
||||
}
|
||||
listOf("minute", "دقائق").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("ساعتان", true) -> {
|
||||
cal.apply { add(Calendar.HOUR, -2) }.timeInMillis
|
||||
}
|
||||
listOf("hour", "ساعات").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("يوم", true) -> {
|
||||
cal.apply { add(Calendar.DAY_OF_YEAR, -1) }.timeInMillis
|
||||
}
|
||||
contains("يومين", true) -> {
|
||||
cal.apply { add(Calendar.DAY_OF_YEAR, -2) }.timeInMillis
|
||||
}
|
||||
listOf("day", "أيام").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.DAY_OF_YEAR, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("أسبوع", true) -> {
|
||||
cal.apply { add(Calendar.WEEK_OF_YEAR, -1) }.timeInMillis
|
||||
}
|
||||
contains("أسبوعين", true) -> {
|
||||
cal.apply { add(Calendar.WEEK_OF_YEAR, -2) }.timeInMillis
|
||||
}
|
||||
listOf("week", "أسابيع").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.WEEK_OF_YEAR, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("شهر", true) -> {
|
||||
cal.apply { add(Calendar.MONTH, -1) }.timeInMillis
|
||||
}
|
||||
contains("شهرين", true) -> {
|
||||
cal.apply { add(Calendar.MONTH, -2) }.timeInMillis
|
||||
}
|
||||
listOf("month", "أشهر").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
contains("سنة", true) -> {
|
||||
cal.apply { add(Calendar.YEAR, -1) }.timeInMillis
|
||||
}
|
||||
contains("سنتان", true) -> {
|
||||
cal.apply { add(Calendar.YEAR, -2) }.timeInMillis
|
||||
}
|
||||
listOf("year", "سنوات").any { contains(it, true) } -> {
|
||||
cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||
}
|
||||
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select("[wire:key^=image] img").mapIndexed { idx, img ->
|
||||
Page(idx, imageUrl = img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package eu.kanade.tachiyomi.extension.all.galaxy
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class GalaxyFactory : SourceFactory {
|
||||
|
||||
class GalaxyWebtoon : Galaxy("Galaxy Webtoon", "https://galaxyaction.net", "en") {
|
||||
override val id = 2602904659965278831
|
||||
}
|
||||
|
||||
class GalaxyManga : Galaxy("Galaxy Manga", "https://ayoub-zrr.xyz", "ar") {
|
||||
override val id = 2729515745226258240
|
||||
}
|
||||
|
||||
override fun createSources() = listOf(
|
||||
GalaxyWebtoon(),
|
||||
GalaxyManga(),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.extension.all.galaxy
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class SelectFilter(
|
||||
name: String,
|
||||
private val options: List<Pair<String, String>>,
|
||||
private val queryParam: String,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
buildList {
|
||||
add("")
|
||||
addAll(options.map { it.first })
|
||||
}.toTypedArray(),
|
||||
) {
|
||||
fun addFilterParameter(url: HttpUrl.Builder) {
|
||||
if (state == 0) return
|
||||
|
||||
url.addQueryParameter(queryParam, options[state - 1].second)
|
||||
}
|
||||
}
|
||||
|
||||
class FilterData(
|
||||
val displayName: String,
|
||||
val options: List<Pair<String, String>>,
|
||||
val queryParameter: String,
|
||||
)
|
|
@ -1,8 +1,8 @@
|
|||
ext {
|
||||
extName = 'Ler Mangá Online'
|
||||
extClass = '.LerMangaOnline'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
ext {
|
||||
extName = '3Hentai'
|
||||
extClass = '.Hentai3Factory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 10 KiB |
|
@ -0,0 +1,207 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentai3
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class Hentai3(
|
||||
override val lang: String = "all",
|
||||
private val searchLang: String = "",
|
||||
) : HttpSource() {
|
||||
|
||||
override val name = "3Hentai"
|
||||
|
||||
override val baseUrl = "https://3hentai.net"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("referer", "$baseUrl/")
|
||||
.set("origin", baseUrl)
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/${if (searchLang.isNotEmpty()) "language/$searchLang/${if (page > 1) page else ""}?" else "search?q=pages%3A>0&pages=$page&"}sort=popular", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
|
||||
val mangas = doc.select("a[href*=/d/]").map(::popularMangaFromElement)
|
||||
val hasNextPage = doc.selectFirst("a[rel=next]") != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
title = element.selectFirst("div")!!.ownText()
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
thumbnail_url = element.selectFirst("img:not([class])")!!.absUrl("src")
|
||||
}
|
||||
}
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/${if (searchLang.isNotEmpty()) "language/$searchLang/$page" else "search?q=pages%3A>0&pages=$page"}", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val tags = mutableListOf<String>()
|
||||
var singleTag: Pair<String, String>? = null
|
||||
var sort = ""
|
||||
|
||||
if (searchLang.isNotEmpty()) tags.add("language:$searchLang")
|
||||
filters.forEach {
|
||||
when (it) {
|
||||
is SelectFilter -> sort = it.getValue()
|
||||
|
||||
is TextFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
val splitted = it.state.split(",").filter(String::isNotBlank)
|
||||
if (splitted.size < 2 && it.type != "tags") {
|
||||
singleTag = it.type to it.state.replace(" ", "-")
|
||||
} else {
|
||||
splitted.map { tag ->
|
||||
val trimmed = tag.trim().lowercase()
|
||||
tags.add(
|
||||
buildString {
|
||||
if (trimmed.startsWith('-')) append("-")
|
||||
append(it.type, ":'")
|
||||
append(trimmed.removePrefix("-"), if (it.specific.isNotEmpty()) " (${it.specific})'" else "'")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (singleTag != null) {
|
||||
addPathSegment(singleTag!!.first)
|
||||
addPathSegment(singleTag!!.second)
|
||||
if (page > 1) addPathSegment(page.toString())
|
||||
} else {
|
||||
addPathSegment("search")
|
||||
addQueryParameter(
|
||||
"q",
|
||||
when {
|
||||
tags.isNotEmpty() -> tags.joinToString()
|
||||
query.isNotEmpty() -> query
|
||||
else -> "page:>0"
|
||||
},
|
||||
)
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
}
|
||||
addQueryParameter("sort", sort)
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
|
||||
fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
|
||||
s.replaceFirstChar { sr ->
|
||||
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
|
||||
}
|
||||
}
|
||||
return SManga.create().apply {
|
||||
val authors = document.select("a[href*=/groups/]").eachText().joinToString()
|
||||
val artists = document.select("a[href*=/artists/]").eachText().joinToString()
|
||||
initialized = true
|
||||
title = document.select("h1 > span").text()
|
||||
author = authors.ifEmpty { artists }
|
||||
artist = artists.ifEmpty { authors }
|
||||
genre = document.select("a[href*=/tags/]").eachText().joinToString {
|
||||
val capitalized = it.capitalizeEach()
|
||||
if (capitalized.contains("male")) {
|
||||
capitalized.replace("(female)", "♀").replace("(male)", "♂")
|
||||
} else {
|
||||
"$capitalized ◊"
|
||||
}
|
||||
}
|
||||
|
||||
description = buildString {
|
||||
document.select("a[href*=/characters/]").eachText().joinToString().ifEmpty { null }?.let {
|
||||
append("Characters: ", it.capitalizeEach(), "\n\n")
|
||||
}
|
||||
document.select("a[href*=/series/]").eachText().joinToString().ifEmpty { null }?.let {
|
||||
append("Series: ", it.capitalizeEach(), "\n\n")
|
||||
}
|
||||
document.select("a[href*=/groups/]").eachText().joinToString().ifEmpty { null }?.let {
|
||||
append("Groups: ", it.capitalizeEach(), "\n\n")
|
||||
}
|
||||
document.select("a[href*=/language/]").eachText().joinToString().ifEmpty { null }?.let {
|
||||
append("Languages: ", it.capitalizeEach(), "\n\n")
|
||||
}
|
||||
|
||||
append(document.select("div.tag-container:contains(pages:)").text(), "\n")
|
||||
}
|
||||
thumbnail_url = document.selectFirst("img[src*=thumbnail].w-96")?.absUrl("src")
|
||||
status = SManga.COMPLETED
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
}
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val doc = response.asJsoup()
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
setUrlWithoutDomain(response.request.url.toString())
|
||||
date_upload = try {
|
||||
dateFormat.parse(doc.select("time").text())!!.time
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val images = response.asJsoup().select("img:not([class], [src*=thumb], [src*=cover])")
|
||||
return images.mapIndexed { index, image ->
|
||||
val imageUrl = image.absUrl("src")
|
||||
Page(index, imageUrl = imageUrl.replace(Regex("t(?=\\.)"), ""))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList() = getFilters()
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentai3
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class Hentai3Factory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
Hentai3("all", ""),
|
||||
Hentai3("en", "english"),
|
||||
Hentai3("ja", "japanese"),
|
||||
Hentai3("ko", "korean"),
|
||||
Hentai3("zh", "chinese"),
|
||||
Hentai3("mo", "mongolian"),
|
||||
Hentai3("es", "spanish"),
|
||||
Hentai3("pt", "Portuguese"),
|
||||
Hentai3("id", "indonesian"),
|
||||
Hentai3("jv", "javanese"),
|
||||
Hentai3("tl", "tagalog"),
|
||||
Hentai3("vi", "vietnamese"),
|
||||
Hentai3("th", "thai"),
|
||||
Hentai3("my", "burmese"),
|
||||
Hentai3("tr", "turkish"),
|
||||
Hentai3("ru", "russian"),
|
||||
Hentai3("uk", "ukrainian"),
|
||||
Hentai3("po", "polish"),
|
||||
Hentai3("fi", "finnish"),
|
||||
Hentai3("de", "german"),
|
||||
Hentai3("it", "italian"),
|
||||
Hentai3("fr", "french"),
|
||||
Hentai3("nl", "dutch"),
|
||||
Hentai3("cs", "czech"),
|
||||
Hentai3("hu", "hungarian"),
|
||||
Hentai3("bg", "bulgarian"),
|
||||
Hentai3("is", "icelandic"),
|
||||
Hentai3("la", "latin"),
|
||||
Hentai3("ar", "arabic"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentai3
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
SelectFilter("Sort by", getSortsList),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
Filter.Header("Use 'Male Tags' or 'Female Tags' for specific categories. 'Tags' searches all categories."),
|
||||
TextFilter("Tags", "tags"),
|
||||
TextFilter("Male Tags", "tags", "male"),
|
||||
TextFilter("Female Tags", "tags", "female"),
|
||||
TextFilter("Series", "series"),
|
||||
TextFilter("Characters", "characters"),
|
||||
TextFilter("Artists", "artist"),
|
||||
TextFilter("Groups", "groups"),
|
||||
TextFilter("Languages", "language"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Filter by pages, for example: (>20)"),
|
||||
TextFilter("Pages", "page"),
|
||||
)
|
||||
}
|
||||
|
||||
internal open class TextFilter(name: String, val type: String, val specific: String = "") : Filter.Text(name)
|
||||
|
||||
internal open class SelectFilter(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
|
||||
}
|
||||
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Recent", ""),
|
||||
Pair("Popular: All Time", "popular"),
|
||||
Pair("Popular: Week", "popular-7d"),
|
||||
Pair("Popular: Today", "popular-24h"),
|
||||
)
|
|
@ -0,0 +1,10 @@
|
|||
ext {
|
||||
extName = 'HentaiEra'
|
||||
extClass = '.HentaiEraFactory'
|
||||
themePkg = 'galleryadults'
|
||||
baseUrl = 'https://hentaiera.com'
|
||||
overrideVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 13 KiB |
|
@ -0,0 +1,105 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentaiera
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.Genre
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.SearchFlagFilter
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.toBinary
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
class HentaiEra(
|
||||
lang: String = "all",
|
||||
override val mangaLang: String = LANGUAGE_MULTI,
|
||||
) : GalleryAdults(
|
||||
"HentaiEra",
|
||||
"https://hentaiera.com",
|
||||
lang = lang,
|
||||
) {
|
||||
override val supportsLatest = true
|
||||
override val useIntermediateSearch: Boolean = true
|
||||
override val supportSpeechless: Boolean = true
|
||||
|
||||
override fun Element.mangaTitle(selector: String): String? =
|
||||
mangaFullTitle(selector.replace("caption", "gallery_title")).let {
|
||||
if (preferences.shortTitle) it?.shortenTitle() else it
|
||||
}
|
||||
|
||||
override fun Element.mangaLang() =
|
||||
select("a:has(.g_flag)").attr("href")
|
||||
.removeSuffix("/").substringAfterLast("/")
|
||||
.let {
|
||||
// Include Speechless in search results
|
||||
if (it == LANGUAGE_SPEECHLESS) mangaLang else it
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
// Only for query string or multiple tags
|
||||
val url = "$baseUrl/search/".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("pp", "1")
|
||||
|
||||
getLanguageURIs().forEach { pair ->
|
||||
addQueryParameter(
|
||||
pair.second,
|
||||
toBinary(mangaLang == pair.first || mangaLang == LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
||||
|
||||
addPageUri(page)
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
/* Details */
|
||||
override fun Element.getInfo(tag: String): String {
|
||||
return select("li:has(.tags_text:contains($tag)) .tag .item_name")
|
||||
.joinToString {
|
||||
val name = it.ownText()
|
||||
if (tag.contains(regexTag)) {
|
||||
genres[name] = it.parent()!!.attr("href")
|
||||
.removeSuffix("/").substringAfterLast('/')
|
||||
}
|
||||
listOf(
|
||||
name,
|
||||
it.select(".split_tag").text()
|
||||
.trim()
|
||||
.removePrefix("| "),
|
||||
)
|
||||
.filter { s -> s.isNotBlank() }
|
||||
.joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun Element.getCover() =
|
||||
selectFirst(".left_cover img")?.imgAttr()
|
||||
|
||||
override fun tagsParser(document: Document): List<Genre> {
|
||||
return document.select("h2.gallery_title a")
|
||||
.mapNotNull {
|
||||
Genre(
|
||||
it.text(),
|
||||
it.attr("href")
|
||||
.removeSuffix("/").substringAfterLast('/'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val mangaDetailInfoSelector = ".gallery_first"
|
||||
|
||||
/* Pages */
|
||||
override val thumbnailSelector = ".gthumb"
|
||||
override val pageUri = "view"
|
||||
|
||||
override fun getCategoryURIs() = listOf(
|
||||
SearchFlagFilter("Manga", "mg"),
|
||||
SearchFlagFilter("Doujinshi", "dj"),
|
||||
SearchFlagFilter("Western", "ws"),
|
||||
SearchFlagFilter("Image Set", "is"),
|
||||
SearchFlagFilter("Artist CG", "ac"),
|
||||
SearchFlagFilter("Game CG", "gc"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentaiera
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class HentaiEraFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
HentaiEra("en", GalleryAdults.LANGUAGE_ENGLISH),
|
||||
HentaiEra("ja", GalleryAdults.LANGUAGE_JAPANESE),
|
||||
HentaiEra("es", GalleryAdults.LANGUAGE_SPANISH),
|
||||
HentaiEra("fr", GalleryAdults.LANGUAGE_FRENCH),
|
||||
HentaiEra("ko", GalleryAdults.LANGUAGE_KOREAN),
|
||||
HentaiEra("de", GalleryAdults.LANGUAGE_GERMAN),
|
||||
HentaiEra("ru", GalleryAdults.LANGUAGE_RUSSIAN),
|
||||
HentaiEra("all", GalleryAdults.LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Hitomi'
|
||||
extClass = '.HitomiFactory'
|
||||
extVersionCode = 28
|
||||
extVersionCode = 31
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -3,79 +3,50 @@ package eu.kanade.tachiyomi.extension.all.hitomi
|
|||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
typealias OrderType = Pair<String?, String>
|
||||
typealias ParsedFilter = Pair<String, OrderType>
|
||||
|
||||
private fun parseFilter(query: StringBuilder, area: String, filterState: String) {
|
||||
filterState
|
||||
.trim()
|
||||
.split(',')
|
||||
.filter { it.isNotBlank() }
|
||||
.forEach {
|
||||
val trimmed = it.trim()
|
||||
val negativePrefix = if (trimmed.startsWith("-")) "-" else ""
|
||||
query.append(" $negativePrefix$area:${trimmed.removePrefix("-").replace(" ", "_")}")
|
||||
}
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
SelectFilter("Sort by", getSortsList),
|
||||
TypeFilter("Types"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
TextFilter("Groups", "group"),
|
||||
TextFilter("Artists", "artist"),
|
||||
TextFilter("Series", "series"),
|
||||
TextFilter("Characters", "character"),
|
||||
TextFilter("Male Tags", "male"),
|
||||
TextFilter("Female Tags", "female"),
|
||||
Filter.Header("Please don't put Female/Male tags here, they won't work!"),
|
||||
TextFilter("Tags", "tag"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseFilters(filters: FilterList): ParsedFilter {
|
||||
val query = StringBuilder()
|
||||
var order: OrderType = Pair("date", "added")
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SortFilter -> {
|
||||
order = filter.getOrder
|
||||
}
|
||||
is AreaFilter -> {
|
||||
parseFilter(query, filter.getAreaName, filter.state)
|
||||
}
|
||||
else -> { /* Do Nothing */ }
|
||||
}
|
||||
}
|
||||
return Pair(query.toString(), order)
|
||||
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
||||
internal open class SelectFilter(name: String, val vals: List<Triple<String, String?, String>>, state: Int = 0) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun getArea() = vals[state].second
|
||||
fun getValue() = vals[state].third
|
||||
}
|
||||
internal class TypeFilter(name: String) :
|
||||
Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
listOf(
|
||||
Pair("Anime", "anime"),
|
||||
Pair("Artist CG", "artistcg"),
|
||||
Pair("Doujinshi", "doujinshi"),
|
||||
Pair("Game CG", "gamecg"),
|
||||
Pair("Image Set", "imageset"),
|
||||
Pair("Manga", "manga"),
|
||||
).map { CheckBoxFilter(it.first, it.second, true) },
|
||||
)
|
||||
internal open class CheckBoxFilter(name: String, val value: String, state: Boolean) : Filter.CheckBox(name, state)
|
||||
|
||||
private class OrderFilter(val name: String, val order: OrderType) {
|
||||
val getFilterName: String
|
||||
get() = name
|
||||
val getOrder: OrderType
|
||||
get() = order
|
||||
}
|
||||
|
||||
private class SortFilter : UriPartFilter(
|
||||
"Sort By",
|
||||
arrayOf(
|
||||
OrderFilter("Date Added", Pair(null, "index")),
|
||||
OrderFilter("Date Published", Pair("date", "published")),
|
||||
OrderFilter("Popular: Today", Pair("popular", "today")),
|
||||
OrderFilter("Popular: Week", Pair("popular", "week")),
|
||||
OrderFilter("Popular: Month", Pair("popular", "month")),
|
||||
OrderFilter("Popular: Year", Pair("popular", "year")),
|
||||
),
|
||||
)
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<OrderFilter>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.getFilterName }.toTypedArray()) {
|
||||
val getOrder: OrderType
|
||||
get() = vals[state].getOrder
|
||||
}
|
||||
|
||||
private class AreaFilter(displayName: String, val areaName: String) :
|
||||
Filter.Text(displayName) {
|
||||
val getAreaName: String
|
||||
get() = areaName
|
||||
}
|
||||
|
||||
fun getFilterListInternal(): FilterList = FilterList(
|
||||
SortFilter(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
AreaFilter("Artist(s)", "artist"),
|
||||
AreaFilter("Character(s)", "character"),
|
||||
AreaFilter("Group(s)", "group"),
|
||||
AreaFilter("Series", "series"),
|
||||
AreaFilter("Female Tag(s)", "female"),
|
||||
AreaFilter("Male Tag(s)", "male"),
|
||||
Filter.Header("Don't put Female/Male tags here, they won't work!"),
|
||||
AreaFilter("Tag(s)", "tag"),
|
||||
private val getSortsList: List<Triple<String, String?, String>> = listOf(
|
||||
Triple("Date Added", null, "index"),
|
||||
Triple("Date Published", "date", "published"),
|
||||
Triple("Popular: Today", "popular", "today"),
|
||||
Triple("Popular: Week", "popular", "week"),
|
||||
Triple("Popular: Month", "popular", "month"),
|
||||
Triple("Popular: Year", "popular", "year"),
|
||||
Triple("Random", "popular", "year"),
|
||||
)
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
|
@ -22,16 +17,16 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
|
@ -41,7 +36,7 @@ import kotlin.math.min
|
|||
class Hitomi(
|
||||
override val lang: String,
|
||||
private val nozomiLang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
) : HttpSource() {
|
||||
|
||||
override val name = "Hitomi"
|
||||
|
||||
|
@ -57,19 +52,13 @@ class Hitomi(
|
|||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private var iconified = preferences.getBoolean(PREF_TAG_GENDER_ICON, false)
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("referer", "$baseUrl/")
|
||||
.set("origin", baseUrl)
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.fromCallable {
|
||||
runBlocking {
|
||||
val entries = getGalleryIDsFromNozomi("popular", "today", nozomiLang, page.nextPageRange())
|
||||
val entries = getGalleryIDsFromNozomi("popular", "year", nozomiLang, page.nextPageRange())
|
||||
.toMangaList()
|
||||
|
||||
MangasPage(entries, entries.size >= 24)
|
||||
|
@ -88,26 +77,23 @@ class Hitomi(
|
|||
private lateinit var searchResponse: List<Int>
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.fromCallable {
|
||||
val parsedFilter = parseFilters(filters)
|
||||
|
||||
runBlocking {
|
||||
if (page == 1) {
|
||||
searchResponse = hitomiSearch(
|
||||
"$query${parsedFilter.first}".trim(),
|
||||
parsedFilter.second,
|
||||
query.trim(),
|
||||
filters,
|
||||
nozomiLang,
|
||||
).toList()
|
||||
)
|
||||
}
|
||||
|
||||
val end = min(page * 25, searchResponse.size)
|
||||
val entries = searchResponse.subList((page - 1) * 25, end)
|
||||
.toMangaList()
|
||||
|
||||
MangasPage(entries, end != searchResponse.size)
|
||||
MangasPage(entries, end < searchResponse.size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList = getFilterListInternal()
|
||||
override fun getFilterList() = getFilters()
|
||||
|
||||
private fun Int.nextPageRange(): LongRange {
|
||||
val byteOffset = ((this - 1) * 25) * 4L
|
||||
|
@ -115,27 +101,73 @@ class Hitomi(
|
|||
}
|
||||
|
||||
private suspend fun getRangedResponse(url: String, range: LongRange?): ByteArray {
|
||||
val rangeHeaders = when (range) {
|
||||
null -> headers
|
||||
else -> headersBuilder()
|
||||
.set("Range", "bytes=${range.first}-${range.last}")
|
||||
.build()
|
||||
val request = when (range) {
|
||||
null -> GET(url, headers)
|
||||
else -> {
|
||||
val rangeHeaders = headersBuilder()
|
||||
.set("Range", "bytes=${range.first}-${range.last}")
|
||||
.build()
|
||||
|
||||
GET(url, rangeHeaders, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
}
|
||||
|
||||
return client.newCall(GET(url, rangeHeaders)).awaitSuccess().use { it.body.bytes() }
|
||||
return client.newCall(request).awaitSuccess().use { it.body.bytes() }
|
||||
}
|
||||
|
||||
private suspend fun hitomiSearch(
|
||||
query: String,
|
||||
order: OrderType,
|
||||
filters: FilterList,
|
||||
language: String = "all",
|
||||
): Set<Int> =
|
||||
): List<Int> =
|
||||
coroutineScope {
|
||||
var sortBy: Pair<String?, String> = Pair(null, "index")
|
||||
var random = false
|
||||
|
||||
val terms = query
|
||||
.trim()
|
||||
.replace(Regex("""^\?"""), "")
|
||||
.lowercase()
|
||||
.split(Regex("\\s+"))
|
||||
.toMutableList()
|
||||
|
||||
filters.forEach {
|
||||
when (it) {
|
||||
is SelectFilter -> {
|
||||
sortBy = Pair(it.getArea(), it.getValue())
|
||||
random = (it.vals[it.state].first == "Random")
|
||||
}
|
||||
|
||||
is TypeFilter -> {
|
||||
val (activeFilter, inactiveFilters) = it.state.partition { stIt -> stIt.state }
|
||||
terms += when {
|
||||
inactiveFilters.size < 5 -> inactiveFilters.map { fil -> "-type:${fil.value}" }
|
||||
inactiveFilters.size == 5 -> listOf("type:${activeFilter[0].value}")
|
||||
else -> listOf("type: none")
|
||||
}
|
||||
}
|
||||
|
||||
is TextFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
terms += it.state.split(",").filter(String::isNotBlank).map { tag ->
|
||||
val trimmed = tag.trim()
|
||||
buildString {
|
||||
if (trimmed.startsWith('-')) {
|
||||
append("-")
|
||||
}
|
||||
append(it.type)
|
||||
append(":")
|
||||
append(trimmed.lowercase().removePrefix("-"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (language != "all" && sortBy == Pair(null, "index") && !terms.any { it.contains(":") }) {
|
||||
terms += "language:$language"
|
||||
}
|
||||
|
||||
val positiveTerms = LinkedList<String>()
|
||||
val negativeTerms = LinkedList<String>()
|
||||
|
@ -150,22 +182,35 @@ class Hitomi(
|
|||
|
||||
val positiveResults = positiveTerms.map {
|
||||
async {
|
||||
runCatching {
|
||||
getGalleryIDsForQuery(it, language, order)
|
||||
}.getOrDefault(emptySet())
|
||||
try {
|
||||
getGalleryIDsForQuery(it, language)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
if (e.message?.equals("HTTP error 404") == true) {
|
||||
throw Exception("Unknown query: \"$it\"")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val negativeResults = negativeTerms.map {
|
||||
async {
|
||||
runCatching {
|
||||
getGalleryIDsForQuery(it, language, order)
|
||||
}.getOrDefault(emptySet())
|
||||
try {
|
||||
getGalleryIDsForQuery(it, language)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
if (e.message?.equals("HTTP error 404") == true) {
|
||||
throw Exception("Unknown query: \"$it\"")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val results = when {
|
||||
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(order.first, order.second, language)
|
||||
positiveTerms.isEmpty() || sortBy != Pair(null, "index")
|
||||
-> getGalleryIDsFromNozomi(sortBy.first, sortBy.second, language)
|
||||
else -> emptySet()
|
||||
}.toMutableSet()
|
||||
|
||||
|
@ -190,14 +235,17 @@ class Hitomi(
|
|||
filterNegative(it.await())
|
||||
}
|
||||
|
||||
results
|
||||
if (random) {
|
||||
results.toList().shuffled()
|
||||
} else {
|
||||
results.toList()
|
||||
}
|
||||
}
|
||||
|
||||
// search.js
|
||||
private suspend fun getGalleryIDsForQuery(
|
||||
query: String,
|
||||
language: String = "all",
|
||||
order: OrderType,
|
||||
): Set<Int> {
|
||||
query.replace("_", " ").let {
|
||||
if (it.indexOf(':') > -1) {
|
||||
|
@ -220,20 +268,6 @@ class Hitomi(
|
|||
}
|
||||
}
|
||||
|
||||
if (area != null) {
|
||||
if (order.first != null) {
|
||||
area = "$area/${order.first}"
|
||||
if (tag.isBlank()) {
|
||||
tag = order.second
|
||||
} else {
|
||||
area = "$area/${order.second}"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
area = order.first
|
||||
tag = order.second
|
||||
}
|
||||
|
||||
return getGalleryIDsFromNozomi(area, tag, lang)
|
||||
}
|
||||
|
||||
|
@ -435,12 +469,18 @@ class Hitomi(
|
|||
private suspend fun Collection<Int>.toMangaList() = coroutineScope {
|
||||
map { id ->
|
||||
async {
|
||||
runCatching {
|
||||
try {
|
||||
client.newCall(GET("$ltnUrl/galleries/$id.js", headers))
|
||||
.awaitSuccess()
|
||||
.parseScriptAs<Gallery>()
|
||||
.toSManga()
|
||||
}.getOrNull()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
if (e.message?.equals("HTTP error 404") == true) {
|
||||
return@async null
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
@ -450,7 +490,7 @@ class Hitomi(
|
|||
url = galleryurl
|
||||
author = groups?.joinToString { it.formatted }
|
||||
artist = artists?.joinToString { it.formatted }
|
||||
genre = tags?.joinToString { it.getFormatted(iconified) }
|
||||
genre = tags?.joinToString { it.formatted }
|
||||
thumbnail_url = files.first().let {
|
||||
val hash = it.hash
|
||||
val imageId = imageIdFromHash(hash)
|
||||
|
@ -459,14 +499,15 @@ class Hitomi(
|
|||
"https://${subDomain}tn.$domain/webpbigtn/${thumbPathFromHash(hash)}/$hash.webp"
|
||||
}
|
||||
description = buildString {
|
||||
parodys?.joinToString { it.formatted }?.let {
|
||||
append("Series: ", it, "\n")
|
||||
}
|
||||
characters?.joinToString { it.formatted }?.let {
|
||||
append("Characters: ", it, "\n")
|
||||
}
|
||||
parodys?.joinToString { it.formatted }?.let {
|
||||
append("Parodies: ", it, "\n")
|
||||
}
|
||||
append("Type: ", type, "\n")
|
||||
append("Pages: ", files.size, "\n")
|
||||
append("Language: ", language)
|
||||
language?.let { append("Language: ", language) }
|
||||
}
|
||||
status = SManga.COMPLETED
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
|
@ -487,26 +528,21 @@ class Hitomi(
|
|||
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = manga.url
|
||||
.substringAfterLast("-")
|
||||
.substringBefore(".")
|
||||
|
||||
return GET("$ltnUrl/galleries/$id.js#${manga.url}", headers)
|
||||
}
|
||||
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val gallery = response.parseScriptAs<Gallery>()
|
||||
val mangaUrl = response.request.url.fragment!!
|
||||
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
url = mangaUrl
|
||||
url = gallery.galleryurl
|
||||
scanlator = gallery.type
|
||||
date_upload = runCatching {
|
||||
date_upload = try {
|
||||
dateFormat.parse(gallery.date.substringBeforeLast("-"))!!.time
|
||||
}.getOrDefault(0L)
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -525,6 +561,9 @@ class Hitomi(
|
|||
|
||||
override fun pageListParse(response: Response) = runBlocking {
|
||||
val gallery = response.parseScriptAs<Gallery>()
|
||||
val id = gallery.galleryurl
|
||||
.substringAfterLast("-")
|
||||
.substringBefore(".")
|
||||
|
||||
gallery.files.mapIndexed { idx, img ->
|
||||
val hash = img.hash
|
||||
|
@ -620,28 +659,9 @@ class Hitomi(
|
|||
|
||||
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_TAG_GENDER_ICON
|
||||
title = "Show gender as text or icon in tags (requires refresh)"
|
||||
summaryOff = "Show gender as text"
|
||||
summaryOn = "Show gender as icon"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
iconified = newValue == true
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
|
||||
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
companion object {
|
||||
private const val PREF_TAG_GENDER_ICON = "pref_tag_gender_icon"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,12 +4,12 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
@Serializable
|
||||
data class Gallery(
|
||||
class Gallery(
|
||||
val galleryurl: String,
|
||||
val title: String,
|
||||
val date: String,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val type: String?,
|
||||
val language: String?,
|
||||
val tags: List<Tag>?,
|
||||
val artists: List<Artist>?,
|
||||
val groups: List<Group>?,
|
||||
|
@ -19,49 +19,49 @@ data class Gallery(
|
|||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageFile(
|
||||
class ImageFile(
|
||||
val hash: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Tag(
|
||||
val female: JsonPrimitive?,
|
||||
val male: JsonPrimitive?,
|
||||
val tag: String,
|
||||
class Tag(
|
||||
private val female: JsonPrimitive?,
|
||||
private val male: JsonPrimitive?,
|
||||
private val tag: String,
|
||||
) {
|
||||
fun getFormatted(iconified: Boolean) = if (female?.content == "1") {
|
||||
tag.toCamelCase() + if (iconified) " ♀" else " (Female)"
|
||||
val formatted get() = if (female?.content == "1") {
|
||||
tag.toCamelCase() + " ♀"
|
||||
} else if (male?.content == "1") {
|
||||
tag.toCamelCase() + if (iconified) " ♂" else " (Male)"
|
||||
tag.toCamelCase() + " ♂"
|
||||
} else {
|
||||
tag.toCamelCase()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val artist: String,
|
||||
class Artist(
|
||||
private val artist: String,
|
||||
) {
|
||||
val formatted get() = artist.toCamelCase()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Group(
|
||||
val group: String,
|
||||
class Group(
|
||||
private val group: String,
|
||||
) {
|
||||
val formatted get() = group.toCamelCase()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Character(
|
||||
val character: String,
|
||||
class Character(
|
||||
private val character: String,
|
||||
) {
|
||||
val formatted get() = character.toCamelCase()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Parody(
|
||||
val parody: String,
|
||||
class Parody(
|
||||
private val parody: String,
|
||||
) {
|
||||
val formatted get() = parody.toCamelCase()
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ ext {
|
|||
extClass = '.MangaFireFactory'
|
||||
themePkg = 'mangareader'
|
||||
baseUrl = 'https://mangafire.to'
|
||||
overrideVersionCode = 3
|
||||
overrideVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -138,13 +138,13 @@ open class MangaFire(
|
|||
override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
|
||||
val result = json.decodeFromString<ResponseDto<String>>(response.body.string()).result
|
||||
val document = Jsoup.parse(result)
|
||||
|
||||
val elements = document.select("ul li")
|
||||
val selector = if (isVolume) "div.unit" else "ul li"
|
||||
val elements = document.select(selector)
|
||||
if (elements.size > 0) {
|
||||
val linkToFirstChapter = elements[0].selectFirst(Evaluator.Tag("a"))!!.attr("href")
|
||||
val mangaId = linkToFirstChapter.toString().substringAfter('.').substringBefore('/')
|
||||
|
||||
val request = GET("$baseUrl/ajax/read/$mangaId/chapter/$langCode", headers)
|
||||
val type = if (isVolume) volumeType else chapterType
|
||||
val request = GET("$baseUrl/ajax/read/$mangaId/$type/$langCode", headers)
|
||||
val response = client.newCall(request).execute()
|
||||
val res = json.decodeFromString<ResponseDto<ChapterIdsDto>>(response.body.string()).result.html
|
||||
val chapterInfoDocument = Jsoup.parse(res)
|
||||
|
@ -177,6 +177,7 @@ open class MangaFire(
|
|||
val element = elements[i]
|
||||
val number = element.attr("data-number").toFloatOrNull() ?: -1f
|
||||
if (chapter.chapter_number != number) throw Exception("Chapter number doesn't match. Try updating again.")
|
||||
chapter.name = element.select(Evaluator.Tag("span"))[0].ownText()
|
||||
val date = element.select(Evaluator.Tag("span"))[1].ownText()
|
||||
chapter.date_upload = try {
|
||||
dateFormat.parse(date)!!.time
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'MangaPark'
|
||||
extClass = '.MangaParkFactory'
|
||||
extVersionCode = 19
|
||||
extVersionCode = 20
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
|||
import android.widget.Toast
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.lib.cookieinterceptor.CookieInterceptor
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
|
@ -17,10 +18,14 @@ 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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
@ -28,6 +33,8 @@ import okhttp3.Response
|
|||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class MangaPark(
|
||||
override val lang: String,
|
||||
|
@ -53,6 +60,7 @@ class MangaPark(
|
|||
private val json: Json by injectLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::siteSettingsInterceptor)
|
||||
.addNetworkInterceptor(CookieInterceptor(domain, "nsfw" to "2"))
|
||||
.rateLimitHost(apiUrl.toHttpUrl(), 1)
|
||||
.build()
|
||||
|
@ -90,8 +98,6 @@ class MangaPark(
|
|||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
runCatching(::getGenres)
|
||||
|
||||
val result = response.parseAs<SearchResponse>()
|
||||
|
||||
val entries = result.data.searchComics.items.map { it.data.toSManga() }
|
||||
|
@ -126,6 +132,10 @@ class MangaPark(
|
|||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching(::getGenres)
|
||||
}
|
||||
|
||||
val filters = mutableListOf<Filter<*>>(
|
||||
SortFilter(),
|
||||
OriginalLanguageFilter(),
|
||||
|
@ -175,7 +185,13 @@ class MangaPark(
|
|||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<ChapterListResponse>()
|
||||
|
||||
return result.data.chapterList.map { it.data.toSChapter() }.reversed()
|
||||
return if (preference.getBoolean(DUPLICATE_CHAPTER_PREF_KEY, false)) {
|
||||
result.data.chapterList.flatMap {
|
||||
it.data.dupChapters.map { it.data.toSChapter() }
|
||||
}.reversed()
|
||||
} else {
|
||||
result.data.chapterList.map { it.data.toSChapter() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast("#")
|
||||
|
@ -211,6 +227,13 @@ class MangaPark(
|
|||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = DUPLICATE_CHAPTER_PREF_KEY
|
||||
title = "Fetch Duplicate Chapters"
|
||||
summary = "Refresh chapter list to apply changes"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T =
|
||||
|
@ -222,6 +245,35 @@ class MangaPark(
|
|||
private inline fun <reified T : Any> T.toJsonRequestBody() =
|
||||
json.encodeToString(this).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
private val cookiesNotSet = AtomicBoolean(true)
|
||||
private val latch = CountDownLatch(1)
|
||||
|
||||
// sets necessary cookies to not block genres like `Hentai`
|
||||
private fun siteSettingsInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
val settingsUrl = "$baseUrl/aok/settings-save"
|
||||
|
||||
if (
|
||||
request.url.toString() != settingsUrl &&
|
||||
request.url.host == domain
|
||||
) {
|
||||
if (cookiesNotSet.getAndSet(false)) {
|
||||
val payload =
|
||||
"""{"data":{"general_autoLangs":[],"general_userLangs":[],"general_excGenres":[],"general_prefLangs":[]}}"""
|
||||
.toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
client.newCall(POST(settingsUrl, headers, payload)).execute().close()
|
||||
|
||||
latch.countDown()
|
||||
} else {
|
||||
latch.await()
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
@ -248,5 +300,7 @@ class MangaPark(
|
|||
"parkmanga.org",
|
||||
"mpark.to",
|
||||
)
|
||||
|
||||
private const val DUPLICATE_CHAPTER_PREF_KEY = "pref_dup_chapters"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,34 +12,34 @@ typealias ChapterListResponse = Data<ChapterList>
|
|||
typealias PageListResponse = Data<ChapterPages>
|
||||
|
||||
@Serializable
|
||||
data class Data<T>(val data: T)
|
||||
class Data<T>(val data: T)
|
||||
|
||||
@Serializable
|
||||
data class Items<T>(val items: List<T>)
|
||||
class Items<T>(val items: List<T>)
|
||||
|
||||
@Serializable
|
||||
data class SearchComics(
|
||||
class SearchComics(
|
||||
@SerialName("get_searchComic") val searchComics: Items<Data<MangaParkComic>>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ComicNode(
|
||||
class ComicNode(
|
||||
@SerialName("get_comicNode") val comic: Data<MangaParkComic>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaParkComic(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val altNames: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val artists: List<String>? = null,
|
||||
val genres: List<String>? = null,
|
||||
val originalStatus: String? = null,
|
||||
val uploadStatus: String? = null,
|
||||
val summary: String? = null,
|
||||
@SerialName("urlCoverOri") val cover: String? = null,
|
||||
val urlPath: String,
|
||||
class MangaParkComic(
|
||||
private val id: String,
|
||||
private val name: String,
|
||||
private val altNames: List<String>? = null,
|
||||
private val authors: List<String>? = null,
|
||||
private val artists: List<String>? = null,
|
||||
private val genres: List<String>? = null,
|
||||
private val originalStatus: String? = null,
|
||||
private val uploadStatus: String? = null,
|
||||
private val summary: String? = null,
|
||||
@SerialName("urlCoverOri") private val cover: String? = null,
|
||||
private val urlPath: String,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = "$urlPath#$id"
|
||||
|
@ -100,18 +100,21 @@ data class MangaParkComic(
|
|||
}
|
||||
|
||||
@Serializable
|
||||
data class ChapterList(
|
||||
class ChapterList(
|
||||
@SerialName("get_comicChapterList") val chapterList: List<Data<MangaParkChapter>>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaParkChapter(
|
||||
val id: String,
|
||||
@SerialName("dname") val displayName: String,
|
||||
val title: String? = null,
|
||||
val dateCreate: Long? = null,
|
||||
val dateModify: Long? = null,
|
||||
val urlPath: String,
|
||||
class MangaParkChapter(
|
||||
private val id: String,
|
||||
@SerialName("dname") private val displayName: String,
|
||||
private val title: String? = null,
|
||||
private val dateCreate: Long? = null,
|
||||
private val dateModify: Long? = null,
|
||||
private val urlPath: String,
|
||||
private val srcTitle: String? = null,
|
||||
private val userNode: Data<Name>? = null,
|
||||
val dupChapters: List<Data<MangaParkChapter>> = emptyList(),
|
||||
) {
|
||||
fun toSChapter() = SChapter.create().apply {
|
||||
url = "$urlPath#$id"
|
||||
|
@ -120,20 +123,24 @@ data class MangaParkChapter(
|
|||
title?.let { append(": ", it) }
|
||||
}
|
||||
date_upload = dateModify ?: dateCreate ?: 0L
|
||||
scanlator = userNode?.data?.name ?: srcTitle ?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChapterPages(
|
||||
class Name(val name: String)
|
||||
|
||||
@Serializable
|
||||
class ChapterPages(
|
||||
@SerialName("get_chapterNode") val chapterPages: Data<ImageFiles>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageFiles(
|
||||
class ImageFiles(
|
||||
val imageFile: UrlList,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UrlList(
|
||||
class UrlList(
|
||||
val urlList: List<String>,
|
||||
)
|
||||
|
|
|
@ -4,28 +4,28 @@ import kotlinx.serialization.SerialName
|
|||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GraphQL<T>(
|
||||
val variables: T,
|
||||
val query: String,
|
||||
class GraphQL<T>(
|
||||
private val variables: T,
|
||||
private val query: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchVariables(val select: SearchPayload)
|
||||
class SearchVariables(private val select: SearchPayload)
|
||||
|
||||
@Serializable
|
||||
data class SearchPayload(
|
||||
@SerialName("word") val query: String? = null,
|
||||
val incGenres: List<String>? = null,
|
||||
val excGenres: List<String>? = null,
|
||||
val incTLangs: List<String>? = null,
|
||||
val incOLangs: List<String>? = null,
|
||||
val sortby: String? = null,
|
||||
val chapCount: String? = null,
|
||||
val origStatus: String? = null,
|
||||
val siteStatus: String? = null,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
class SearchPayload(
|
||||
@SerialName("word") private val query: String? = null,
|
||||
private val incGenres: List<String>? = null,
|
||||
private val excGenres: List<String>? = null,
|
||||
private val incTLangs: List<String>? = null,
|
||||
private val incOLangs: List<String>? = null,
|
||||
private val sortby: String? = null,
|
||||
private val chapCount: String? = null,
|
||||
private val origStatus: String? = null,
|
||||
private val siteStatus: String? = null,
|
||||
private val page: Int,
|
||||
private val size: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IdVariables(val id: String)
|
||||
class IdVariables(private val id: String)
|
||||
|
|
|
@ -75,6 +75,28 @@ val CHAPTERS_QUERY = buildQuery {
|
|||
dateModify
|
||||
dateCreate
|
||||
urlPath
|
||||
srcTitle
|
||||
userNode {
|
||||
data {
|
||||
name
|
||||
}
|
||||
}
|
||||
dupChapters {
|
||||
data {
|
||||
id
|
||||
dname
|
||||
title
|
||||
dateModify
|
||||
dateCreate
|
||||
urlPath
|
||||
srcTitle
|
||||
userNode {
|
||||
data {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Meitua.top'
|
||||
extClass = '.MeituaTop'
|
||||
extVersionCode = 5
|
||||
extVersionCode = 6
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ class MeituaTop : HttpSource() {
|
|||
override val lang = "all"
|
||||
override val supportsLatest = false
|
||||
|
||||
override val baseUrl = "https://meitu1.xyz"
|
||||
override val baseUrl = "https://mt1.meitu1.sbs"
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/arttype/0b-$page.html", headers)
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
ext {
|
||||
extName = 'PandaChaika'
|
||||
extClass = '.PandaChaikaFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 17 KiB |
|
@ -0,0 +1,253 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pandachaika
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.lang.String.CASE_INSENSITIVE_ORDER
|
||||
import java.math.BigInteger
|
||||
|
||||
class PandaChaika(
|
||||
override val lang: String = "all",
|
||||
private val searchLang: String = "",
|
||||
) : HttpSource() {
|
||||
|
||||
override val name = "PandaChaika"
|
||||
|
||||
override val baseUrl = "https://panda.chaika.moe"
|
||||
|
||||
private val baseSearchUrl = "$baseUrl/search"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
.newBuilder()
|
||||
.addInterceptor(::Intercept)
|
||||
.build()
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseSearchUrl/?tags=$searchLang&sort=rating&apply=&json=&page=$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseSearchUrl/?tags=$searchLang&sort=public_date&apply=&json=&page=$page", headers)
|
||||
}
|
||||
|
||||
private fun parsePageRange(query: String, minPages: Int = 1, maxPages: Int = 9999): Pair<Int, Int> {
|
||||
val num = query.filter(Char::isDigit).toIntOrNull() ?: -1
|
||||
fun limitedNum(number: Int = num): Int = number.coerceIn(minPages, maxPages)
|
||||
|
||||
if (num < 0) return minPages to maxPages
|
||||
return when (query.firstOrNull()) {
|
||||
'<' -> 1 to if (query[1] == '=') limitedNum() else limitedNum(num + 1)
|
||||
'>' -> limitedNum(if (query[1] == '=') num else num + 1) to maxPages
|
||||
'=' -> when (query[1]) {
|
||||
'>' -> limitedNum() to maxPages
|
||||
'<' -> 1 to limitedNum(maxPages)
|
||||
else -> limitedNum() to limitedNum()
|
||||
}
|
||||
else -> limitedNum() to limitedNum()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val library = response.parseAs<ArchiveResponse>()
|
||||
|
||||
val mangas = library.archives.map(LongArchive::toSManga)
|
||||
|
||||
val hasNextPage = library.has_next
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = baseSearchUrl.toHttpUrl().newBuilder().apply {
|
||||
val tags = mutableListOf<String>()
|
||||
var reason = ""
|
||||
var uploader = ""
|
||||
var pagesMin = 1
|
||||
var pagesMax = 9999
|
||||
|
||||
tags.add(searchLang)
|
||||
|
||||
filters.forEach {
|
||||
when (it) {
|
||||
is SortFilter -> {
|
||||
addQueryParameter("sort", it.getValue())
|
||||
addQueryParameter("asc_desc", if (it.state!!.ascending) "asc" else "desc")
|
||||
}
|
||||
|
||||
is SelectFilter -> {
|
||||
addQueryParameter("category", it.vals[it.state].replace("All", ""))
|
||||
}
|
||||
|
||||
is PageFilter -> {
|
||||
if (it.state.isNotBlank()) {
|
||||
val (min, max) = parsePageRange(it.state)
|
||||
pagesMin = min
|
||||
pagesMax = max
|
||||
}
|
||||
}
|
||||
|
||||
is TextFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
when (it.type) {
|
||||
"reason" -> reason = it.state
|
||||
"uploader" -> uploader = it.state
|
||||
else -> {
|
||||
it.state.split(",").filter(String::isNotBlank).map { tag ->
|
||||
val trimmed = tag.trim()
|
||||
tags.add(
|
||||
buildString {
|
||||
if (trimmed.startsWith('-')) append("-")
|
||||
append(it.type)
|
||||
if (it.type.isNotBlank()) append(":")
|
||||
append(trimmed.lowercase().removePrefix("-"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
addQueryParameter("title", query)
|
||||
addQueryParameter("tags", tags.joinToString())
|
||||
addQueryParameter("filecount_from", pagesMin.toString())
|
||||
addQueryParameter("filecount_to", pagesMax.toString())
|
||||
addQueryParameter("reason", reason)
|
||||
addQueryParameter("uploader", uploader)
|
||||
addQueryParameter("page", page.toString())
|
||||
addQueryParameter("apply", "")
|
||||
addQueryParameter("json", "")
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl/api?archive=${manga.url}", headers)
|
||||
}
|
||||
|
||||
override fun getFilterList() = getFilters()
|
||||
|
||||
// Details
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return Observable.just(manga.apply { initialized = true })
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val archive = response.parseAs<Archive>()
|
||||
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
url = archive.download.substringBefore("/download/")
|
||||
date_upload = archive.posted * 1000
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = "$baseUrl/archive/${manga.url}"
|
||||
override fun getChapterUrl(chapter: SChapter) = "$baseUrl${chapter.url}"
|
||||
|
||||
// Pages
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
fun List<String>.sort() = this.sortedWith(compareBy(CASE_INSENSITIVE_ORDER) { it })
|
||||
val url = "$baseUrl${chapter.url}/download/"
|
||||
val (fileType, contentLength) = getZipType(url)
|
||||
|
||||
val remoteZip = ZipHandler(url, client, headers, fileType, contentLength).populate()
|
||||
val fileListing = remoteZip.files().sort()
|
||||
|
||||
val files = remoteZip.toJson()
|
||||
return Observable.just(
|
||||
fileListing.mapIndexed { index, filename ->
|
||||
Page(index, imageUrl = "https://127.0.0.1/#$filename&$files")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getZipType(url: String): Pair<String, BigInteger> {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.method("HEAD", null)
|
||||
.build()
|
||||
|
||||
val contentLength = (
|
||||
client.newCall(request).execute().header("content-length")
|
||||
?: throw Exception("Could not get Content-Length of URL")
|
||||
)
|
||||
.toBigInteger()
|
||||
|
||||
return (if (contentLength > Int.MAX_VALUE.toBigInteger()) "zip64" else "zip") to contentLength
|
||||
}
|
||||
|
||||
private fun Intercept(chain: Interceptor.Chain): Response {
|
||||
val url = chain.request().url.toString()
|
||||
return if (url.startsWith("https://127.0.0.1/#")) {
|
||||
val fragment = url.toHttpUrl().fragment!!
|
||||
val remoteZip = fragment.substringAfter("&").parseAs<Zip>()
|
||||
val filename = fragment.substringBefore("&")
|
||||
|
||||
val byteArray = remoteZip.fetch(filename, client)
|
||||
var type = filename.substringAfterLast('.').lowercase()
|
||||
type = if (type == "jpg") "jpeg" else type
|
||||
|
||||
Response.Builder().body(byteArray.toResponseBody("image/$type".toMediaType()))
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_0)
|
||||
.code(200)
|
||||
.message("")
|
||||
.build()
|
||||
} else {
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return json.decodeFromString(body.string())
|
||||
}
|
||||
|
||||
private inline fun <reified T> String.parseAs(): T {
|
||||
return json.decodeFromString(this)
|
||||
}
|
||||
|
||||
private fun Zip.toJson(): String {
|
||||
return json.encodeToString(this)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
override fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException()
|
||||
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException()
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pandachaika
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
|
||||
fun filterTags(include: String = "", exclude: List<String> = emptyList(), tags: List<String>): String {
|
||||
return tags.filter { it.startsWith("$include:") && exclude.none { substring -> it.startsWith("$substring:") } }
|
||||
.joinToString {
|
||||
it.substringAfter(":").replace("_", " ").split(" ").joinToString(" ") { s ->
|
||||
s.replaceFirstChar { sr ->
|
||||
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun getReadableSize(bytes: Double): String {
|
||||
return when {
|
||||
bytes >= 300 * 1024 * 1024 -> "${"%.2f".format(bytes / (1024.0 * 1024.0 * 1024.0))} GB"
|
||||
bytes >= 100 * 1024 -> "${"%.2f".format(bytes / (1024.0 * 1024.0))} MB"
|
||||
bytes >= 1024 -> "${"%.2f".format(bytes / (1024.0))} KB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Archive(
|
||||
val download: String,
|
||||
val posted: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class LongArchive(
|
||||
private val thumbnail: String,
|
||||
private val title: String,
|
||||
private val id: Int,
|
||||
private val posted: Long?,
|
||||
private val public_date: Long?,
|
||||
private val filecount: Int,
|
||||
private val filesize: Double,
|
||||
private val tags: List<String>,
|
||||
private val title_jpn: String?,
|
||||
private val uploader: String,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
val groups = filterTags("group", tags = tags)
|
||||
val artists = filterTags("artist", tags = tags)
|
||||
val publishers = filterTags("publisher", tags = tags)
|
||||
val male = filterTags("male", tags = tags)
|
||||
val female = filterTags("female", tags = tags)
|
||||
val others = filterTags(exclude = listOf("female", "male", "artist", "publisher", "group", "parody"), tags = tags)
|
||||
val parodies = filterTags("parody", tags = tags)
|
||||
url = id.toString()
|
||||
title = this@LongArchive.title
|
||||
thumbnail_url = thumbnail
|
||||
author = groups.ifEmpty { artists }
|
||||
artist = artists
|
||||
genre = listOf(male, female, others).joinToString()
|
||||
description = buildString {
|
||||
append("Uploader: ", uploader.ifEmpty { "Anonymous" }, "\n")
|
||||
publishers.takeIf { it.isNotBlank() }?.let {
|
||||
append("Publishers: ", it, "\n\n")
|
||||
}
|
||||
parodies.takeIf { it.isNotBlank() }?.let {
|
||||
append("Parodies: ", it, "\n\n")
|
||||
}
|
||||
male.takeIf { it.isNotBlank() }?.let {
|
||||
append("Male tags: ", it, "\n\n")
|
||||
}
|
||||
female.takeIf { it.isNotBlank() }?.let {
|
||||
append("Female tags: ", it, "\n\n")
|
||||
}
|
||||
others.takeIf { it.isNotBlank() }?.let {
|
||||
append("Other tags: ", it, "\n\n")
|
||||
}
|
||||
|
||||
title_jpn?.let { append("Japanese Title: ", it, "\n") }
|
||||
append("Pages: ", filecount, "\n")
|
||||
append("File Size: ", getReadableSize(filesize), "\n")
|
||||
|
||||
try {
|
||||
append("Public Date: ", dateReformat.format(Date(public_date!! * 1000)), "\n")
|
||||
} catch (_: Exception) {}
|
||||
try {
|
||||
append("Posted: ", dateReformat.format(Date(posted!! * 1000)), "\n")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
status = SManga.COMPLETED
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ArchiveResponse(
|
||||
val archives: List<LongArchive>,
|
||||
val has_next: Boolean,
|
||||
)
|
|
@ -0,0 +1,29 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pandachaika
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class PandaChaikaFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
PandaChaika(),
|
||||
PandaChaika("en", "english"),
|
||||
PandaChaika("zh", "chinese"),
|
||||
PandaChaika("ko", "korean"),
|
||||
PandaChaika("es", "spanish"),
|
||||
PandaChaika("ru", "russian"),
|
||||
PandaChaika("pt", "portuguese"),
|
||||
PandaChaika("fr", "french"),
|
||||
PandaChaika("th", "thai"),
|
||||
PandaChaika("vi", "vietnamese"),
|
||||
PandaChaika("ja", "japanese"),
|
||||
PandaChaika("id", "indonesian"),
|
||||
PandaChaika("ar", "arabic"),
|
||||
PandaChaika("uk", "ukrainian"),
|
||||
PandaChaika("tr", "turkish"),
|
||||
PandaChaika("cs", "czech"),
|
||||
PandaChaika("tl", "tagalog"),
|
||||
PandaChaika("fi", "finnish"),
|
||||
PandaChaika("jv", "javanese"),
|
||||
PandaChaika("el", "greek"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pandachaika
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Sort.Selection
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
SortFilter("Sort by", Selection(0, false), getSortsList),
|
||||
SelectFilter("Types", getTypes),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
Filter.Header("Use 'Male Tags' or 'Female Tags' for specific categories. 'Tags' searches all categories."),
|
||||
TextFilter("Tags", ""),
|
||||
TextFilter("Male Tags", "male"),
|
||||
TextFilter("Female Tags", "female"),
|
||||
TextFilter("Artists", "artist"),
|
||||
TextFilter("Parodies", "parody"),
|
||||
Filter.Separator(),
|
||||
TextFilter("Reason", "reason"),
|
||||
TextFilter("Uploader", "reason"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Filter by pages, for example: (>20)"),
|
||||
PageFilter("Pages"),
|
||||
)
|
||||
}
|
||||
|
||||
internal open class PageFilter(name: String) : Filter.Text(name)
|
||||
|
||||
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
||||
|
||||
internal open class SelectFilter(name: String, val vals: List<String>, state: Int = 0) :
|
||||
Filter.Select<String>(name, vals.map { it }.toTypedArray(), state)
|
||||
|
||||
internal open class SortFilter(name: String, selection: Selection, private val vals: List<Pair<String, String>>) :
|
||||
Filter.Sort(name, vals.map { it.first }.toTypedArray(), selection) {
|
||||
fun getValue() = vals[state!!.index].second
|
||||
}
|
||||
|
||||
private val getTypes = listOf(
|
||||
"All",
|
||||
"Doujinshi",
|
||||
"Manga",
|
||||
"Image Set",
|
||||
"Artist CG",
|
||||
"Game CG",
|
||||
"Western",
|
||||
"Non-H",
|
||||
"Misc",
|
||||
)
|
||||
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Public Date", "public_date"),
|
||||
Pair("Posted Date", "posted_date"),
|
||||
Pair("Title", "title"),
|
||||
Pair("Japanese Title", "title_jpn"),
|
||||
Pair("Rating", "rating"),
|
||||
Pair("Images", "images"),
|
||||
Pair("File Size", "size"),
|
||||
Pair("Category", "category"),
|
||||
)
|
|
@ -0,0 +1,287 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pandachaika
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.pandachaika.ZipParser.inflateRaw
|
||||
import eu.kanade.tachiyomi.extension.all.pandachaika.ZipParser.parseAllCDs
|
||||
import eu.kanade.tachiyomi.extension.all.pandachaika.ZipParser.parseEOCD
|
||||
import eu.kanade.tachiyomi.extension.all.pandachaika.ZipParser.parseEOCD64
|
||||
import eu.kanade.tachiyomi.extension.all.pandachaika.ZipParser.parseLocalFile
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder.LITTLE_ENDIAN
|
||||
import java.util.zip.Inflater
|
||||
import kotlin.text.Charsets.UTF_8
|
||||
|
||||
const val CENTRAL_DIRECTORY_FILE_HEADER_SIGNATURE = 0x02014b50
|
||||
const val END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50
|
||||
const val END_OF_CENTRAL_DIRECTORY_64_SIGNATURE = 0x06064b50
|
||||
const val LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
|
||||
|
||||
class EndOfCentralDirectory(
|
||||
val centralDirectoryByteSize: BigInteger,
|
||||
val centralDirectoryByteOffset: BigInteger,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class CentralDirectoryRecord(
|
||||
val length: Int,
|
||||
val compressedSize: Int,
|
||||
val localFileHeaderRelativeOffset: Int,
|
||||
val filename: String,
|
||||
)
|
||||
|
||||
class LocalFileHeader(
|
||||
val compressedData: ByteArray,
|
||||
val compressionMethod: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Zip(
|
||||
private val url: String,
|
||||
private val centralDirectoryRecords: List<CentralDirectoryRecord>,
|
||||
) {
|
||||
fun files(): List<String> {
|
||||
return centralDirectoryRecords.map {
|
||||
it.filename
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(path: String, client: OkHttpClient): ByteArray {
|
||||
val file = centralDirectoryRecords.find { it.filename == path }
|
||||
?: throw Exception("File not found in ZIP: $path")
|
||||
|
||||
val MAX_LOCAL_FILE_HEADER_SIZE = 256 + 32 + 30 + 100
|
||||
|
||||
val headersBuilder = Headers.Builder()
|
||||
.set(
|
||||
"Range",
|
||||
"bytes=${file.localFileHeaderRelativeOffset}-${
|
||||
file.localFileHeaderRelativeOffset +
|
||||
file.compressedSize +
|
||||
MAX_LOCAL_FILE_HEADER_SIZE
|
||||
}",
|
||||
).build()
|
||||
|
||||
val request = GET(url, headersBuilder)
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
val byteArray = response.body.byteStream().use { it.readBytes() }
|
||||
|
||||
val localFile = parseLocalFile(byteArray, file.compressedSize)
|
||||
?: throw Exception("Failed to parse local file header in ZIP")
|
||||
|
||||
return if (localFile.compressionMethod == 0) {
|
||||
localFile.compressedData
|
||||
} else {
|
||||
inflateRaw(localFile.compressedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ZipHandler(
|
||||
private val url: String,
|
||||
private val client: OkHttpClient,
|
||||
private val additionalHeaders: Headers = Headers.Builder().build(),
|
||||
private val zipType: String = "zip",
|
||||
private val contentLength: BigInteger,
|
||||
) {
|
||||
fun populate(): Zip {
|
||||
val endOfCentralDirectory = fetchEndOfCentralDirectory(contentLength, zipType)
|
||||
val centralDirectoryRecords = fetchCentralDirectoryRecords(endOfCentralDirectory)
|
||||
|
||||
return Zip(
|
||||
url,
|
||||
centralDirectoryRecords,
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchEndOfCentralDirectory(zipByteLength: BigInteger, zipType: String): EndOfCentralDirectory {
|
||||
val EOCD_MAX_BYTES = 128.toBigInteger()
|
||||
val eocdInitialOffset = maxOf(0.toBigInteger(), zipByteLength - EOCD_MAX_BYTES)
|
||||
|
||||
val headers = additionalHeaders
|
||||
.newBuilder()
|
||||
.set("Range", "bytes=$eocdInitialOffset-$zipByteLength")
|
||||
.build()
|
||||
val request = GET(url, headers)
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Could not fetch ZIP: HTTP status ${response.code}")
|
||||
}
|
||||
|
||||
val eocdBuffer = response.body.byteStream().use { it.readBytes() }
|
||||
|
||||
if (eocdBuffer.isEmpty()) throw Exception("Could not get Range request to start looking for EOCD")
|
||||
|
||||
val eocd =
|
||||
(if (zipType == "zip64") parseEOCD64(eocdBuffer) else parseEOCD(eocdBuffer))
|
||||
?: throw Exception("Could not get EOCD record of the ZIP")
|
||||
|
||||
return eocd
|
||||
}
|
||||
|
||||
private fun fetchCentralDirectoryRecords(endOfCentralDirectory: EndOfCentralDirectory): List<CentralDirectoryRecord> {
|
||||
val headersBuilder = Headers.Builder()
|
||||
.set(
|
||||
"Range",
|
||||
"bytes=${endOfCentralDirectory.centralDirectoryByteOffset}-${
|
||||
endOfCentralDirectory.centralDirectoryByteOffset +
|
||||
endOfCentralDirectory.centralDirectoryByteSize
|
||||
}",
|
||||
).build()
|
||||
|
||||
val request = GET(url, headersBuilder)
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
val cdBuffer = response.body.byteStream().use { it.readBytes() }
|
||||
|
||||
return parseAllCDs(cdBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
object ZipParser {
|
||||
|
||||
fun parseAllCDs(buffer: ByteArray): List<CentralDirectoryRecord> {
|
||||
val cds = ArrayList<CentralDirectoryRecord>()
|
||||
val view = ByteBuffer.wrap(buffer).order(LITTLE_ENDIAN)
|
||||
|
||||
var i = 0
|
||||
while (i <= buffer.size - 4) {
|
||||
val signature = view.getInt(i)
|
||||
if (signature == CENTRAL_DIRECTORY_FILE_HEADER_SIGNATURE) {
|
||||
val cd = parseCD(buffer.sliceArray(i until buffer.size))
|
||||
if (cd != null) {
|
||||
cds.add(cd)
|
||||
i += cd.length - 1
|
||||
continue
|
||||
}
|
||||
} else if (signature == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return cds
|
||||
}
|
||||
|
||||
fun parseCD(buffer: ByteArray): CentralDirectoryRecord? {
|
||||
val MIN_CD_LENGTH = 46
|
||||
val view = ByteBuffer.wrap(buffer).order(LITTLE_ENDIAN)
|
||||
|
||||
for (i in 0..buffer.size - MIN_CD_LENGTH) {
|
||||
if (view.getInt(i) == CENTRAL_DIRECTORY_FILE_HEADER_SIGNATURE) {
|
||||
val filenameLength = view.getShort(i + 28).toInt()
|
||||
val extraFieldLength = view.getShort(i + 30).toInt()
|
||||
val fileCommentLength = view.getShort(i + 32).toInt()
|
||||
|
||||
return CentralDirectoryRecord(
|
||||
length = 46 + filenameLength + extraFieldLength + fileCommentLength,
|
||||
compressedSize = view.getInt(i + 20),
|
||||
localFileHeaderRelativeOffset = view.getInt(i + 42),
|
||||
filename = buffer.sliceArray(i + 46 until i + 46 + filenameLength).toString(UTF_8),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseEOCD(buffer: ByteArray): EndOfCentralDirectory? {
|
||||
val MIN_EOCD_LENGTH = 22
|
||||
val view = ByteBuffer.wrap(buffer).order(LITTLE_ENDIAN)
|
||||
|
||||
for (i in 0 until buffer.size - MIN_EOCD_LENGTH + 1) {
|
||||
if (view.getInt(i) == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||
return EndOfCentralDirectory(
|
||||
centralDirectoryByteSize = view.getInt(i + 12).toBigInteger(),
|
||||
centralDirectoryByteOffset = view.getInt(i + 16).toBigInteger(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseEOCD64(buffer: ByteArray): EndOfCentralDirectory? {
|
||||
val MIN_EOCD_LENGTH = 56
|
||||
val view = ByteBuffer.wrap(buffer).order(LITTLE_ENDIAN)
|
||||
|
||||
for (i in 0 until buffer.size - MIN_EOCD_LENGTH + 1) {
|
||||
if (view.getInt(i) == END_OF_CENTRAL_DIRECTORY_64_SIGNATURE) {
|
||||
return EndOfCentralDirectory(
|
||||
centralDirectoryByteSize = view.getLong(i + 40).toBigInteger(),
|
||||
centralDirectoryByteOffset = view.getLong(i + 48).toBigInteger(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseLocalFile(buffer: ByteArray, compressedSizeOverride: Int = 0): LocalFileHeader? {
|
||||
val MIN_LOCAL_FILE_LENGTH = 30
|
||||
|
||||
val view = ByteBuffer.wrap(buffer).order(LITTLE_ENDIAN)
|
||||
|
||||
for (i in 0..buffer.size - MIN_LOCAL_FILE_LENGTH) {
|
||||
if (view.getInt(i) == LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
val filenameLength = view.getShort(i + 26).toInt() and 0xFFFF
|
||||
val extraFieldLength = view.getShort(i + 28).toInt() and 0xFFFF
|
||||
|
||||
val bitflags = view.getShort(i + 6).toInt() and 0xFFFF
|
||||
val hasDataDescriptor = (bitflags shr 3) and 1 != 0
|
||||
|
||||
val headerEndOffset = i + 30 + filenameLength + extraFieldLength
|
||||
val regularCompressedSize = view.getInt(i + 18)
|
||||
|
||||
val compressedData = if (hasDataDescriptor) {
|
||||
buffer.copyOfRange(
|
||||
headerEndOffset,
|
||||
headerEndOffset + compressedSizeOverride,
|
||||
)
|
||||
} else {
|
||||
buffer.copyOfRange(
|
||||
headerEndOffset,
|
||||
headerEndOffset + regularCompressedSize,
|
||||
)
|
||||
}
|
||||
|
||||
return LocalFileHeader(
|
||||
compressedData = compressedData,
|
||||
compressionMethod = view.getShort(i + 8).toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun inflateRaw(compressedData: ByteArray): ByteArray {
|
||||
val inflater = Inflater(true)
|
||||
inflater.setInput(compressedData)
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
val output = ByteArrayOutputStream()
|
||||
|
||||
try {
|
||||
while (!inflater.finished()) {
|
||||
val count = inflater.inflate(buffer)
|
||||
if (count > 0) {
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Invalid compressed data format: ${e.message}", e)
|
||||
} finally {
|
||||
inflater.end()
|
||||
output.close()
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Pururin'
|
||||
extClass = '.PururinFactory'
|
||||
extVersionCode = 8
|
||||
extVersionCode = 9
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -7,27 +7,33 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
|||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class Pururin(
|
||||
override val lang: String = "all",
|
||||
private val searchLang: String? = null,
|
||||
private val searchLang: Pair<String, String>? = null,
|
||||
private val langPath: String = "",
|
||||
) : ParsedHttpSource() {
|
||||
override val name = "Pururin"
|
||||
|
||||
override val baseUrl = "https://pururin.to"
|
||||
final override val baseUrl = "https://pururin.to"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
// Popular
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/browse$langPath?sort=most-popular&page=$page", headers)
|
||||
}
|
||||
|
@ -45,7 +51,6 @@ abstract class Pururin(
|
|||
override fun popularMangaNextPageSelector(): String = ".page-item [rel=next]"
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/browse$langPath?page=$page", headers)
|
||||
}
|
||||
|
@ -58,40 +63,131 @@ abstract class Pururin(
|
|||
|
||||
// Search
|
||||
|
||||
private fun List<String>.toValue(): String {
|
||||
return "[${this.joinToString(",")}]"
|
||||
private fun List<Pair<String, String>>.toValue(): String {
|
||||
return "[${this.joinToString(",") { "{\"id\":${it.first},\"name\":\"${it.second}\"}" }}]"
|
||||
}
|
||||
|
||||
private fun parsePageRange(query: String, minPages: Int = 1, maxPages: Int = 9999): Pair<Int, Int> {
|
||||
val num = query.filter(Char::isDigit).toIntOrNull() ?: -1
|
||||
fun limitedNum(number: Int = num): Int = number.coerceIn(minPages, maxPages)
|
||||
|
||||
if (num < 0) return minPages to maxPages
|
||||
return when (query.firstOrNull()) {
|
||||
'<' -> 1 to if (query[1] == '=') limitedNum() else limitedNum(num + 1)
|
||||
'>' -> limitedNum(if (query[1] == '=') num else num + 1) to maxPages
|
||||
'=' -> when (query[1]) {
|
||||
'>' -> limitedNum() to maxPages
|
||||
'<' -> 1 to limitedNum(maxPages)
|
||||
else -> limitedNum() to limitedNum()
|
||||
}
|
||||
else -> limitedNum() to limitedNum()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Tag(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
private fun findTagByNameSubstring(tags: List<Tag>, substring: String): Pair<String, String>? {
|
||||
val tag = tags.find { it.name.contains(substring, ignoreCase = true) }
|
||||
return tag?.let { Pair(tag.id.toString(), tag.name) }
|
||||
}
|
||||
|
||||
private fun tagSearch(tag: String, type: String): Pair<String, String>? {
|
||||
val requestBody = FormBody.Builder()
|
||||
.add("text", tag)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/get/tags/search")
|
||||
.headers(headers)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
return findTagByNameSubstring(response.parseAs<List<Tag>>(), type)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val includeTags = mutableListOf<String>()
|
||||
val excludeTags = mutableListOf<String>()
|
||||
var pagesMin: Int
|
||||
var pagesMax: Int
|
||||
val includeTags = mutableListOf<Pair<String, String>>()
|
||||
val excludeTags = mutableListOf<Pair<String, String>>()
|
||||
var pagesMin = 1
|
||||
var pagesMax = 9999
|
||||
var sortBy = "newest"
|
||||
|
||||
if (searchLang != null) includeTags.add(searchLang)
|
||||
|
||||
filters.filterIsInstance<TagGroup<*>>().map { group ->
|
||||
group.state.map {
|
||||
if (it.isIncluded()) includeTags.add(it.id)
|
||||
if (it.isExcluded()) excludeTags.add(it.id)
|
||||
filters.forEach {
|
||||
when (it) {
|
||||
is SelectFilter -> sortBy = it.getValue()
|
||||
|
||||
is TypeFilter -> {
|
||||
val (_, inactiveFilters) = it.state.partition { stIt -> stIt.state }
|
||||
excludeTags += inactiveFilters.map { fil -> Pair(fil.value, "${fil.name} [Category]") }
|
||||
}
|
||||
|
||||
is PageFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
val (min, max) = parsePageRange(it.state)
|
||||
pagesMin = min
|
||||
pagesMax = max
|
||||
}
|
||||
}
|
||||
|
||||
is TextFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
it.state.split(",").filter(String::isNotBlank).map { tag ->
|
||||
val trimmed = tag.trim()
|
||||
if (trimmed.startsWith('-')) {
|
||||
tagSearch(trimmed.lowercase().removePrefix("-"), it.type)?.let { tagInfo ->
|
||||
excludeTags.add(tagInfo)
|
||||
}
|
||||
} else {
|
||||
tagSearch(trimmed.lowercase(), it.type)?.let { tagInfo ->
|
||||
includeTags.add(tagInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
filters.find<PagesGroup>().range.let {
|
||||
pagesMin = it.first
|
||||
pagesMax = it.last
|
||||
// Searching with just one tag usually gives wrong results
|
||||
if (query.isEmpty()) {
|
||||
when {
|
||||
excludeTags.size == 1 && includeTags.isEmpty() -> excludeTags.addAll(excludeTags)
|
||||
includeTags.size == 1 && excludeTags.isEmpty() -> {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("browse")
|
||||
addPathSegment("tags")
|
||||
addPathSegment("content")
|
||||
addPathSegment(includeTags[0].first)
|
||||
addQueryParameter("sort", sortBy)
|
||||
addQueryParameter("start_page", pagesMin.toString())
|
||||
addQueryParameter("last_page", pagesMax.toString())
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
}.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("search")
|
||||
addQueryParameter("q", query)
|
||||
addQueryParameter("sort", sortBy)
|
||||
addQueryParameter("start_page", pagesMin.toString())
|
||||
addQueryParameter("last_page", pagesMax.toString())
|
||||
if (includeTags.isNotEmpty()) addQueryParameter("included_tags", includeTags.toValue())
|
||||
if (excludeTags.isNotEmpty()) addQueryParameter("excluded_tags", excludeTags.toValue())
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
}
|
||||
return GET(url.build().toString(), headers)
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String = popularMangaSelector()
|
||||
|
@ -107,8 +203,13 @@ abstract class Pururin(
|
|||
document.select(".box-gallery").let { e ->
|
||||
initialized = true
|
||||
title = e.select(".title").text()
|
||||
author = e.select("[itemprop=author]").text()
|
||||
author = e.select("a[href*=/circle/]").text().ifEmpty { e.select("[itemprop=author]").text() }
|
||||
artist = e.select("[itemprop=author]").text()
|
||||
genre = e.select("a[href*=/content/]").text()
|
||||
description = e.select(".box-gallery .table-info tr")
|
||||
.filter { tr ->
|
||||
tr.select("td").none { it.text().contains("content", ignoreCase = true) || it.text().contains("ratings", ignoreCase = true) }
|
||||
}
|
||||
.joinToString("\n") { tr ->
|
||||
tr.select("td")
|
||||
.joinToString(": ") { it.text() }
|
||||
|
@ -156,8 +257,8 @@ abstract class Pururin(
|
|||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
CategoryGroup(),
|
||||
PagesGroup(),
|
||||
)
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return json.decodeFromString(body.string())
|
||||
}
|
||||
override fun getFilterList() = getFilters()
|
||||
}
|
||||
|
|
|
@ -14,11 +14,11 @@ class PururinFactory : SourceFactory {
|
|||
class PururinAll : Pururin()
|
||||
class PururinEN : Pururin(
|
||||
"en",
|
||||
"{\"id\":13010,\"name\":\"English [Language]\"}",
|
||||
Pair("13010", "english"),
|
||||
"/tags/language/13010/english",
|
||||
)
|
||||
class PururinJA : Pururin(
|
||||
"ja",
|
||||
"{\"id\":13011,\"name\":\"Japanese [Language]\"}",
|
||||
Pair("13011", "japanese"),
|
||||
"/tags/language/13011/japanese",
|
||||
)
|
||||
|
|
|
@ -1,57 +1,57 @@
|
|||
package eu.kanade.tachiyomi.extension.all.pururin
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
sealed class TagFilter(
|
||||
name: String,
|
||||
val id: String,
|
||||
) : Filter.TriState(name)
|
||||
|
||||
sealed class TagGroup<T : TagFilter>(
|
||||
name: String,
|
||||
values: List<T>,
|
||||
) : Filter.Group<T>(name, values)
|
||||
|
||||
class Category(name: String, id: String) : TagFilter(name, id)
|
||||
|
||||
class CategoryGroup(
|
||||
values: List<Category> = categories,
|
||||
) : TagGroup<Category>("Categories", values) {
|
||||
companion object {
|
||||
private val categories get() = listOf(
|
||||
Category("Doujinshi", "{\"id\":13003,\"name\":\"Doujinshi [Category]\"}"),
|
||||
Category("Manga", "{\"id\":13004,\"name\":\"Manga [Category]\"}"),
|
||||
Category("Artist CG", "{\"id\":13006,\"name\":\"Artist CG [Category]\"}"),
|
||||
Category("Game CG", "{\"id\":13008,\"name\":\"Game CG [Category]\"}"),
|
||||
Category("Artbook", "{\"id\":17783,\"name\":\"Artbook [Category]\"}"),
|
||||
Category("Webtoon", "{\"id\":27939,\"name\":\"Webtoon [Category]\"}"),
|
||||
)
|
||||
}
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
SelectFilter("Sort by", getSortsList),
|
||||
TypeFilter("Types"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
TextFilter("Tags", "[Content]"),
|
||||
TextFilter("Artists", "[Artist]"),
|
||||
TextFilter("Circles", "[Circle]"),
|
||||
TextFilter("Parodies", "[Parody]"),
|
||||
TextFilter("Languages", "[Language]"),
|
||||
TextFilter("Scanlators", "[Scanlator]"),
|
||||
TextFilter("Conventions", "[Convention]"),
|
||||
TextFilter("Collections", "[Collections]"),
|
||||
TextFilter("Categories", "[Category]"),
|
||||
TextFilter("Uploaders", "[Uploader]"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Filter by pages, for example: (>20)"),
|
||||
PageFilter("Pages"),
|
||||
)
|
||||
}
|
||||
internal class TypeFilter(name: String) :
|
||||
Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
listOf(
|
||||
Pair("Artbook", "17783"),
|
||||
Pair("Artist CG", "13004"),
|
||||
Pair("Doujinshi", "13003"),
|
||||
Pair("Game CG", "13008"),
|
||||
Pair("Manga", "13004"),
|
||||
Pair("Webtoon", "27939"),
|
||||
).map { CheckBoxFilter(it.first, it.second, true) },
|
||||
)
|
||||
|
||||
class PagesFilter(
|
||||
name: String,
|
||||
default: Int,
|
||||
values: Array<Int> = range,
|
||||
) : Filter.Select<Int>(name, values, default) {
|
||||
companion object {
|
||||
private val range get() = Array(301) { it }
|
||||
}
|
||||
internal open class CheckBoxFilter(name: String, val value: String, state: Boolean) : Filter.CheckBox(name, state)
|
||||
|
||||
internal open class PageFilter(name: String) : Filter.Text(name)
|
||||
|
||||
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
||||
|
||||
internal open class SelectFilter(name: String, val vals: List<Pair<String, String>>, state: Int = 0) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun getValue() = vals[state].second
|
||||
}
|
||||
|
||||
class PagesGroup(
|
||||
values: List<PagesFilter> = minmax,
|
||||
) : Filter.Group<PagesFilter>("Pages", values) {
|
||||
inline val range get() = IntRange(state[0].state, state[1].state).also {
|
||||
require(it.first <= it.last) { "'Minimum' cannot exceed 'Maximum'" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val minmax get() = listOf(
|
||||
PagesFilter("Minimum", 0),
|
||||
PagesFilter("Maximum", 300),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> List<Filter<*>>.find() = find { it is T } as T
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Newest", "newest"),
|
||||
Pair("Most Popular", "most-popular"),
|
||||
Pair("Highest Rated", "highest-rated"),
|
||||
Pair("Most Viewed", "most-viewed"),
|
||||
Pair("Title", "title"),
|
||||
)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
ext {
|
||||
extName = 'Union Mangas'
|
||||
extClass = '.UnionMangasFactory'
|
||||
extVersionCode = 3
|
||||
extVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 14 KiB |
|
@ -1,6 +1,5 @@
|
|||
package eu.kanade.tachiyomi.extension.all.unionmangas
|
||||
|
||||
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
|
@ -10,22 +9,16 @@ 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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
|
||||
override val lang = langOption.lang
|
||||
|
@ -38,39 +31,12 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
|
|||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
val langApiInfix = when (lang) {
|
||||
"it" -> langOption.infix
|
||||
else -> "v3/po"
|
||||
}
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.rateLimit(5, 2, TimeUnit.SECONDS)
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
private fun apiHeaders(url: String): Headers {
|
||||
val date = apiDateFormat.format(Date())
|
||||
val path = url.toUrlWithoutDomain()
|
||||
|
||||
return headersBuilder()
|
||||
.add("_hash", authorization(apiSeed, domain, date))
|
||||
.add("_tranId", authorization(apiSeed, domain, date, path))
|
||||
.add("_date", date)
|
||||
.add("_domain", domain)
|
||||
.add("_path", path)
|
||||
.add("Origin", baseUrl)
|
||||
.add("Host", apiUrl.removeProtocol())
|
||||
.add("Referer", "$baseUrl/")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun authorization(vararg payloads: String): String {
|
||||
val md = MessageDigest.getInstance("MD5")
|
||||
val bytes = payloads.joinToString("").toByteArray()
|
||||
val digest = md.digest(bytes)
|
||||
return digest
|
||||
.fold("") { str, byte -> str + "%02x".format(byte) }
|
||||
.padStart(32, '0')
|
||||
}
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
|
@ -79,95 +45,101 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
|
|||
var currentPage = 0
|
||||
do {
|
||||
val chaptersDto = fetchChapterListPageable(manga, currentPage)
|
||||
chapters += chaptersDto.toSChapter(langOption)
|
||||
chapters += chaptersDto.data.map { chapter ->
|
||||
SChapter.create().apply {
|
||||
name = chapter.name
|
||||
date_upload = chapter.date.toDate()
|
||||
url = chapter.toChapterUrl(langOption.infix)
|
||||
}
|
||||
}
|
||||
currentPage++
|
||||
} while (chaptersDto.hasNextPage())
|
||||
return Observable.just(chapters.reversed())
|
||||
return Observable.just(chapters)
|
||||
}
|
||||
|
||||
private fun fetchChapterListPageable(manga: SManga, page: Int): ChapterPageDto {
|
||||
private fun fetchChapterListPageable(manga: SManga, page: Int): Pageable<ChapterDto> {
|
||||
manga.apply {
|
||||
url = getURLCompatibility(url)
|
||||
}
|
||||
|
||||
val maxResult = 16
|
||||
val url = "$apiUrl/api/$langApiInfix/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/ASC"
|
||||
return client.newCall(GET(url, apiHeaders(url))).execute()
|
||||
.parseAs<ChapterPageDto>()
|
||||
val url = "$apiUrl/${langOption.infix}/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/ASC"
|
||||
return client.newCall(GET(url, headers)).execute()
|
||||
.parseAs<Pageable<ChapterDto>>()
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val nextData = response.parseNextData<LatestUpdateProps>()
|
||||
val dto = nextData.data.latestUpdateDto
|
||||
val mangas = dto.mangas.map { mangaParse(it, nextData.query) }
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val maxResult = 24
|
||||
val url = "$apiUrl/${langOption.infix}/HomeLastUpdate".toHttpUrl().newBuilder()
|
||||
.addPathSegment("$maxResult")
|
||||
.addPathSegment("${page - 1}")
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
manga.apply {
|
||||
url = getURLCompatibility(url)
|
||||
}
|
||||
|
||||
return baseUrl + manga.url.replace(langOption.infix, langOption.mangaSubstring)
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
manga.apply {
|
||||
url = getURLCompatibility(url)
|
||||
}
|
||||
|
||||
val url = "$apiUrl/${langOption.infix}/getInfoManga".toHttpUrl().newBuilder()
|
||||
.addPathSegment(manga.slug())
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val dto = response.parseAs<MangaDetailsDto>()
|
||||
return mangaParse(dto.details)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val chapterSlug = getURLCompatibility(chapter.url)
|
||||
.substringAfter(langOption.infix)
|
||||
|
||||
val url = "$apiUrl/${langOption.infix}/GetImageChapter$chapterSlug"
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val location = response.request.url.toString()
|
||||
val dto = response.parseAs<PageDto>()
|
||||
return dto.pages.mapIndexed { index, url ->
|
||||
Page(index, location, imageUrl = url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<Pageable<MangaDto>>()
|
||||
val mangas = dto.data.map(::mangaParse)
|
||||
return MangasPage(
|
||||
mangas = mangas,
|
||||
hasNextPage = dto.hasNextPage(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$baseUrl/${langOption.infix}/latest-releases".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("page", "$page")
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val maxResult = 24
|
||||
return GET("$apiUrl/${langOption.infix}/HomeTopFllow/$maxResult/${page - 1}")
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val nextData = response.parseNextData<MangaDetailsProps>()
|
||||
val dto = nextData.data.mangaDetailsDto
|
||||
return SManga.create().apply {
|
||||
title = dto.title
|
||||
genre = dto.genres
|
||||
thumbnail_url = dto.thumbnailUrl
|
||||
url = mangaUrlParse(dto.slug, nextData.query.type)
|
||||
status = dto.status
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chaptersDto = decryptChapters(response)
|
||||
return chaptersDto.images.mapIndexed { index, imageUrl ->
|
||||
Page(index, imageUrl = imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptChapters(response: Response): ChaptersDto {
|
||||
val document = response.asJsoup()
|
||||
val password = findChapterPassword(document)
|
||||
val pageListData = document.parseNextData<ChaptersProps>().data.pageListData
|
||||
val decodedData = CryptoAES.decrypt(pageListData, password)
|
||||
return ChaptersDto(
|
||||
data = json.decodeFromString<ChaptersDto>(decodedData).data,
|
||||
delimiter = langOption.pageDelimiter,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findChapterPassword(document: Document): String {
|
||||
val regxPasswordUrl = """\/pages\/%5Btype%5D\/%5Bidmanga%5D\/%5Biddetail%5D-.+\.js""".toRegex()
|
||||
val regxFindPassword = """AES\.decrypt\(\w+,"(?<password>[^"]+)"\)""".toRegex(RegexOption.MULTILINE)
|
||||
val jsDecryptUrl = document.select("script")
|
||||
.map { it.absUrl("src") }
|
||||
.first { regxPasswordUrl.find(it) != null }
|
||||
val jsDecrypt = client.newCall(GET(jsDecryptUrl, headers)).execute().asJsoup().html()
|
||||
return regxFindPassword.find(jsDecrypt)?.groups?.get("password")!!.value.trim()
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val dto = response.parseNextData<PopularMangaProps>()
|
||||
val mangas = dto.data.mangas.map { it.details }.map { mangaParse(it, dto.query) }
|
||||
return MangasPage(
|
||||
mangas = mangas,
|
||||
hasNextPage = false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/${langOption.infix}")
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val maxResult = 6
|
||||
val url = "$apiUrl/api/$langApiInfix/searchforms/$maxResult/".toHttpUrl().newBuilder()
|
||||
val maxResult = 20
|
||||
val url = "$apiUrl/${langOption.infix}/QuickSearch/".toHttpUrl().newBuilder()
|
||||
.addPathSegment(query)
|
||||
.addPathSegment("${page - 1}")
|
||||
.addPathSegment("$maxResult")
|
||||
.build()
|
||||
return GET(url, apiHeaders(url.toString()))
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
|
@ -185,52 +157,54 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
|
|||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val mangasDto = response.parseAs<MangaListDto>().apply {
|
||||
currentPage = response.request.url.pathSegments.last()
|
||||
}
|
||||
|
||||
val dto = response.parseAs<SearchDto>()
|
||||
return MangasPage(
|
||||
mangas = mangasDto.toSManga(langOption.infix),
|
||||
hasNextPage = mangasDto.hasNextPage(),
|
||||
dto.mangas.map(::mangaParse),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseNextData() = asJsoup().parseNextData<T>()
|
||||
/*
|
||||
* Keeps compatibility with pt-BR previous version
|
||||
* */
|
||||
private fun getURLCompatibility(url: String): String {
|
||||
val slugSuffix = "-br"
|
||||
val mangaSubString = "manga-br"
|
||||
|
||||
private inline fun <reified T> Document.parseNextData(): NextData<T> {
|
||||
val jsonContent = selectFirst("script#__NEXT_DATA__")!!.html()
|
||||
return json.decodeFromString<NextData<T>>(jsonContent)
|
||||
val oldSlug = url.substringAfter(mangaSubString)
|
||||
.substring(1)
|
||||
.split("/")
|
||||
.first()
|
||||
|
||||
val newSlug = oldSlug.substringBeforeLast(slugSuffix)
|
||||
|
||||
return url.replace(oldSlug, newSlug)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return json.decodeFromString(body.string())
|
||||
}
|
||||
|
||||
private fun String.removeProtocol() = trim().replace("https://", "")
|
||||
|
||||
private fun SManga.slug() = this.url.split("/").last()
|
||||
|
||||
private fun String.toUrlWithoutDomain() = trim().replace(apiUrl, "")
|
||||
|
||||
private fun mangaParse(dto: MangaDto, query: QueryDto): SManga {
|
||||
private fun mangaParse(dto: MangaDto): SManga {
|
||||
return SManga.create().apply {
|
||||
title = dto.title
|
||||
thumbnail_url = dto.thumbnailUrl
|
||||
status = dto.status
|
||||
url = mangaUrlParse(dto.slug, query.type)
|
||||
url = "/${langOption.infix}/${dto.slug}"
|
||||
genre = dto.genres
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaUrlParse(slug: String, pathSegment: String) = "/$pathSegment/$slug"
|
||||
private fun String.toDate(): Long =
|
||||
try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L }
|
||||
|
||||
companion object {
|
||||
const val SEARCH_PREFIX = "slug:"
|
||||
val apiUrl = "https://api.unionmanga.xyz"
|
||||
val apiSeed = "8e0550790c94d6abc71d738959a88d209690dc86"
|
||||
val domain = "yaoi-chan.xyz"
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
|
||||
val apiDateFormat = SimpleDateFormat("EE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH)
|
||||
.apply { timeZone = TimeZone.getTimeZone("GMT") }
|
||||
val apiUrl = "https://app.unionmanga.xyz/api"
|
||||
val oldApiUrl = "https://api.unionmanga.xyz"
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
|
|