Compare commits
193 Commits
07eba2f8f2
...
c66abf25b9
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c66abf25b9 | ||
![]() |
87f31ed04b | ||
![]() |
ebaccb53c1 | ||
![]() |
39919d1e0a | ||
![]() |
2a0022b3c7 | ||
![]() |
b9282c11ec | ||
![]() |
94ec34b7ab | ||
![]() |
3297e41d08 | ||
![]() |
488ec7a2cd | ||
![]() |
36f9e925f2 | ||
![]() |
86ea37c3a5 | ||
![]() |
69d400b0ef | ||
![]() |
067d4a6bec | ||
![]() |
adb389334b | ||
![]() |
6a6ea56420 | ||
![]() |
16bda48c56 | ||
![]() |
8f429616b4 | ||
![]() |
4fbe372043 | ||
![]() |
377e2124f1 | ||
![]() |
8fb7d54fee | ||
![]() |
a8e083aa40 | ||
![]() |
c44aba3f1a | ||
![]() |
314c5e0ed3 | ||
![]() |
61f37300ed | ||
![]() |
d3fa36c82d | ||
![]() |
d0357da16a | ||
![]() |
526b8ec979 | ||
![]() |
062e3f84bd | ||
![]() |
508f71b204 | ||
![]() |
b44f7c144d | ||
![]() |
913eddbbfc | ||
![]() |
2905e17a9a | ||
![]() |
db840dd353 | ||
![]() |
3e1688e565 | ||
![]() |
0b3628f24e | ||
![]() |
3c91f2a834 | ||
![]() |
d50732286a | ||
![]() |
adbfe86669 | ||
![]() |
ea75b2c202 | ||
![]() |
0d16a6ca77 | ||
![]() |
7d55595507 | ||
![]() |
d4e22509bc | ||
![]() |
c407c67d0f | ||
![]() |
7503c2d20e | ||
![]() |
8955f2b7e4 | ||
![]() |
2b26d7b3a6 | ||
![]() |
c06e206cda | ||
![]() |
d3d573fe77 | ||
![]() |
9f6f2d8b12 | ||
![]() |
8e13bcd5b6 | ||
![]() |
e5f3f65c25 | ||
![]() |
9d01606868 | ||
![]() |
f3e9430b50 | ||
![]() |
2466c0c159 | ||
![]() |
9e3028508e | ||
![]() |
698a02de9e | ||
![]() |
93348a57a3 | ||
![]() |
a79efc512c | ||
![]() |
aad47bab3c | ||
![]() |
06c8247f12 | ||
![]() |
ecb1dd6899 | ||
![]() |
5d8df09535 | ||
![]() |
d4e960700e | ||
![]() |
fd023a8000 | ||
![]() |
302ac4b0cb | ||
![]() |
06aaa51240 | ||
![]() |
c4ff02c6c4 | ||
![]() |
956b7f05e0 | ||
![]() |
07509542fd | ||
![]() |
e3cbc49e38 | ||
![]() |
a56eb29dec | ||
![]() |
fe5b10916f | ||
![]() |
30e8681278 | ||
![]() |
19543c9bba | ||
![]() |
0bf5c08f50 | ||
![]() |
c9d562c63a | ||
![]() |
312747fb1a | ||
![]() |
0da807ff70 | ||
![]() |
1a6774af59 | ||
![]() |
5818f1dc64 | ||
![]() |
590f013578 | ||
![]() |
38f511b815 | ||
![]() |
f8a44eb538 | ||
![]() |
ee29f284f5 | ||
![]() |
83631d067e | ||
![]() |
54d9318a35 | ||
![]() |
67a9995b03 | ||
![]() |
5e02defd77 | ||
![]() |
548d76cfc6 | ||
![]() |
9dfa9a89a0 | ||
![]() |
7c7ce285cd | ||
![]() |
b1ef508489 | ||
![]() |
e853527587 | ||
![]() |
2edb3b6164 | ||
![]() |
6433c41cb7 | ||
![]() |
9fc03a5357 | ||
![]() |
bea0c8897d | ||
![]() |
cba5267312 | ||
![]() |
9ad099295c | ||
![]() |
1c2876f7a5 | ||
![]() |
f3cf3488e8 | ||
![]() |
2dcd6d13a0 | ||
![]() |
b59238f938 | ||
![]() |
37e0aaeb75 | ||
![]() |
ad829436f1 | ||
![]() |
b6ab8fb843 | ||
![]() |
e6866d43c2 | ||
![]() |
f7e3684d45 | ||
![]() |
b709f76b96 | ||
![]() |
c9fc08676f | ||
![]() |
4524da7e08 | ||
![]() |
219ceaac1e | ||
![]() |
1393a25fbb | ||
![]() |
22ba4be2b6 | ||
![]() |
962a22aa34 | ||
![]() |
877ebd33c7 | ||
![]() |
ae6d455bb8 | ||
![]() |
c6e4780feb | ||
![]() |
aa64e66055 | ||
![]() |
4c89276d16 | ||
![]() |
6c87c1634b | ||
![]() |
2dee930bbf | ||
![]() |
7586d7ff61 | ||
![]() |
400079e2ae | ||
![]() |
c6a92ce7c4 | ||
![]() |
c8de3f1c9d | ||
![]() |
a607d7024d | ||
![]() |
17d6151584 | ||
![]() |
1d2fc4493c | ||
![]() |
54055cb43c | ||
![]() |
3af754cb20 | ||
![]() |
9b89cc0b99 | ||
![]() |
0ffd571f18 | ||
![]() |
b425b94051 | ||
![]() |
b9a05d4fcc | ||
![]() |
fceca33d30 | ||
![]() |
fe7d5c5019 | ||
![]() |
eb9b9b9aee | ||
![]() |
ac48b271c1 | ||
![]() |
364c90339a | ||
![]() |
d0ea9fadc6 | ||
![]() |
8dcfac5ba8 | ||
![]() |
d8e635afac | ||
![]() |
14117d1c5f | ||
![]() |
334dd69fab | ||
![]() |
ebd527364e | ||
![]() |
47d14b6f29 | ||
![]() |
e91a361ad8 | ||
![]() |
68c6a5a6af | ||
![]() |
3fb869e5e2 | ||
![]() |
45581e3697 | ||
![]() |
85c4793096 | ||
![]() |
fcb1ff01ad | ||
![]() |
f1fd001e3f | ||
![]() |
1e3a5d8906 | ||
![]() |
7cdee6623f | ||
![]() |
1233a3199a | ||
![]() |
dd95e39ebc | ||
![]() |
fd0fe685f5 | ||
![]() |
303b789fff | ||
![]() |
86369167da | ||
![]() |
cfc8624c40 | ||
![]() |
f210f27a7c | ||
![]() |
9639b7c585 | ||
![]() |
7dcde729a6 | ||
![]() |
383d9c5535 | ||
![]() |
fad76bc4b2 | ||
![]() |
40a9d2ec6a | ||
![]() |
6341d5ff73 | ||
![]() |
485c7f078e | ||
![]() |
153934bed7 | ||
![]() |
b7e40990d8 | ||
![]() |
10cea1e57d | ||
![]() |
7457106c23 | ||
![]() |
752920a0d4 | ||
![]() |
c124f76d64 | ||
![]() |
34bd753d27 | ||
![]() |
d0d9558eb2 | ||
![]() |
7fcbbec4c7 | ||
![]() |
cea3c8a60b | ||
![]() |
26eb2757b4 | ||
![]() |
e64df9ebc4 | ||
![]() |
e5a63cc2e6 | ||
![]() |
98bd2586fb | ||
![]() |
49c03653f8 | ||
![]() |
2723d5d0ca | ||
![]() |
f4a08ea908 | ||
![]() |
9efc599e9c | ||
![]() |
09d9b33080 | ||
![]() |
fa09f8122d | ||
![]() |
5de9ae2485 | ||
![]() |
ae982b97ca | ||
![]() |
8ee6d0184e |
2
.git-blame-ignore-revs
Normal file
@ -0,0 +1,2 @@
|
||||
# Force \n line ending, trailing newline
|
||||
b72776e1f528c56ae6a9ef3a6f8d72533b00d39f
|
2
.github/ISSUE_TEMPLATE/01_report_issue.yml
vendored
@ -99,7 +99,7 @@ body:
|
||||
required: true
|
||||
- label: I have updated all installed extensions.
|
||||
required: true
|
||||
- label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
|
||||
- label: I have tried the [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
||||
required: true
|
||||
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/tachiyomiorg/tachiyomi/issues/new/choose).
|
||||
required: true
|
||||
|
14
README.md
@ -1,14 +1,18 @@
|
||||
# Keiyoushi Extensions
|
||||
|
||||
### Please give the repo a :star:
|
||||
|
||||
| Build | Support Server |
|
||||
| Build | Need Help? |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [](https://github.com/keiyoushi/extensions-source/actions/workflows/build_push.yml) | [](https://discord.gg/3FbCpdKbdY) |
|
||||
|
||||
# Usage
|
||||
## Usage
|
||||
**If you are new to repository/extensions, please read the [Keiyoushi Getting Started guide](https://keiyoushi.github.io/docs/guides/getting-started#adding-the-extension-repo) first.**
|
||||
|
||||
[Getting started](https://keiyoushi.github.io/docs/guides/getting-started#adding-the-extension-repo)
|
||||
* You can add our repo by visiting the [Keiyoushi Website](https://keiyoushi.github.io/add-repo)
|
||||
* Otherwise, copy & paste the following URL: https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json
|
||||
|
||||
# Requests
|
||||
## Requests
|
||||
|
||||
To request a new source or bug fix, [create an issue](https://github.com/keiyoushi/extensions-source/issues/new/choose).
|
||||
|
||||
@ -19,7 +23,7 @@ difficult to maintain.
|
||||
If you would like to see a request fulfilled and have the necessary skills to do so, consider contributing!
|
||||
Issues are up-for-grabs for any developer if there is no assigned user already.
|
||||
|
||||
# Contributing
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
|
@ -1,247 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.a3manga
|
||||
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
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
|
||||
import java.util.TimeZone
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
open class A3Manga(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest: Boolean = false
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder().add("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/page/$page/", headers)
|
||||
|
||||
override fun popularMangaSelector() = ".comic-list .comic-item"
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.select(".comic-title-link a").attr("href"))
|
||||
title = element.select(".comic-title").text().trim()
|
||||
thumbnail_url = element.select(".img-thumbnail").attr("abs:src")
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "li.next:not(.disabled)"
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PREFIX_ID_SEARCH) -> {
|
||||
val id = query.removePrefix(PREFIX_ID_SEARCH).trim()
|
||||
fetchMangaDetails(
|
||||
SManga.create().apply {
|
||||
url = "/truyen-tranh/$id/"
|
||||
},
|
||||
)
|
||||
.map {
|
||||
it.url = "/truyen-tranh/$id/"
|
||||
MangasPage(listOf(it), false)
|
||||
}
|
||||
}
|
||||
else -> super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
|
||||
POST(
|
||||
"$baseUrl/wp-admin/admin-ajax.php",
|
||||
headers,
|
||||
FormBody.Builder()
|
||||
.add("action", "searchtax")
|
||||
.add("keyword", query)
|
||||
.build(),
|
||||
)
|
||||
|
||||
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<SearchResponseDto>()
|
||||
|
||||
if (!dto.success) {
|
||||
return MangasPage(emptyList(), false)
|
||||
}
|
||||
|
||||
val manga = dto.data
|
||||
.filter { it.cstatus != "Nhóm dịch" }
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(it.link)
|
||||
title = it.title
|
||||
thumbnail_url = it.img
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(manga, false)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.select(".info-title").text()
|
||||
author = document.select(".comic-info strong:contains(Tác giả) + span").text().trim()
|
||||
description = document.select(".intro-container .text-justify").text().substringBefore("— Xem Thêm —")
|
||||
genre = document.select(".comic-info .tags a").joinToString { tag ->
|
||||
tag.text().split(' ').joinToString(separator = " ") { word ->
|
||||
word.replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
}
|
||||
thumbnail_url = document.select(".img-thumbnail").attr("abs:src")
|
||||
|
||||
val statusString = document.select(".comic-info strong:contains(Tình trạng) + span").text()
|
||||
status = when (statusString) {
|
||||
"Đang tiến hành" -> SManga.ONGOING
|
||||
"Trọn bộ " -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String = ".chapter-table table tbody tr"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.select("a").attr("href"))
|
||||
name = element.select("a .hidden-sm").text()
|
||||
date_upload = runCatching {
|
||||
dateFormat.parse(element.select("td").last()!!.text())?.time
|
||||
}.getOrNull() ?: 0
|
||||
}
|
||||
|
||||
protected fun decodeImgList(document: Document): String {
|
||||
val htmlContentScript = document.selectFirst("script:containsData(htmlContent)")?.html()
|
||||
?.substringAfter("var htmlContent=\"")
|
||||
?.substringBefore("\";")
|
||||
?.replace("\\\"", "\"")
|
||||
?.replace("\\\\", "\\")
|
||||
?.replace("\\/", "/")
|
||||
?: throw Exception("Couldn't find script with image data.")
|
||||
val htmlContent = json.decodeFromString<CipherDto>(htmlContentScript)
|
||||
val ciphertext = Base64.decode(htmlContent.ciphertext, Base64.DEFAULT)
|
||||
val iv = htmlContent.iv.decodeHex()
|
||||
val salt = htmlContent.salt.decodeHex()
|
||||
|
||||
val passwordScript = document.selectFirst("script:containsData(chapterHTML)")?.html()
|
||||
?: throw Exception("Couldn't find password to decrypt image data.")
|
||||
val passphrase = passwordScript.substringAfter("var chapterHTML=CryptoJSAesDecrypt('")
|
||||
.substringBefore("',htmlContent")
|
||||
.replace("'+'", "")
|
||||
|
||||
val keyFactory = SecretKeyFactory.getInstance(KEY_ALGORITHM)
|
||||
val spec = PBEKeySpec(passphrase.toCharArray(), salt, 999, 256)
|
||||
val keyS = SecretKeySpec(keyFactory.generateSecret(spec).encoded, "AES")
|
||||
|
||||
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(iv))
|
||||
|
||||
val imgListHtml = cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
|
||||
|
||||
return imgListHtml
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val imgListHtml = decodeImgList(document)
|
||||
|
||||
return Jsoup.parseBodyFragment(imgListHtml).select("img").mapIndexed { idx, element ->
|
||||
val encryptedUrl = element.attributes().find { it.key.startsWith("data") }?.value
|
||||
val effectiveUrl = encryptedUrl?.decodeUrl() ?: element.attr("abs:src")
|
||||
Page(idx, imageUrl = effectiveUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.decodeUrl(): String? {
|
||||
// We expect the URL to start with `https://`, where the last 3 characters are encoded.
|
||||
// The length of the encoded character is not known, but it is the same across all.
|
||||
// Essentially we are looking for the two encoded slashes, which tells us the length.
|
||||
val patternIdx = patternsLengthCheck.indexOfFirst { pattern ->
|
||||
val matchResult = pattern.find(this)
|
||||
val g1 = matchResult?.groupValues?.get(1)
|
||||
val g2 = matchResult?.groupValues?.get(2)
|
||||
g1 == g2 && g1 != null
|
||||
}
|
||||
if (patternIdx == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// With a known length we can predict all the encoded characters.
|
||||
// This is a slightly more expensive pattern, hence the separation.
|
||||
val matchResult = patternsSubstitution[patternIdx].find(this)
|
||||
return matchResult?.destructured?.let { (colon, slash, period) ->
|
||||
this
|
||||
.replace(colon, ":")
|
||||
.replace(slash, "/")
|
||||
.replace(period, ".")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return json.decodeFromString(body.string())
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/66614516
|
||||
private fun String.decodeHex(): ByteArray {
|
||||
check(length % 2 == 0) { "Must have an even length" }
|
||||
|
||||
return chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_ALGORITHM = "PBKDF2WithHmacSHA512"
|
||||
const val CIPHER_TRANSFORMATION = "AES/CBC/PKCS7PADDING"
|
||||
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh")
|
||||
}
|
||||
|
||||
private val patternsLengthCheck: List<Regex> = (20 downTo 1).map { i ->
|
||||
"""^https.{$i}(.{$i})(.{$i})""".toRegex()
|
||||
}
|
||||
private val patternsSubstitution: List<Regex> = (20 downTo 1).map { i ->
|
||||
"""^https(.{$i})(.{$i}).*(.{$i})(?:webp|jpeg|tiff|.{3})$""".toRegex()
|
||||
}
|
||||
}
|
||||
}
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 3
|
||||
baseVersionCode = 2
|
BIN
lib-multisrc/greenshit/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
lib-multisrc/greenshit/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
lib-multisrc/greenshit/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
lib-multisrc/greenshit/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
lib-multisrc/greenshit/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 26 KiB |
@ -0,0 +1,322 @@
|
||||
package eu.kanade.tachiyomi.multisrc.greenshit
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import app.cash.quickjs.QuickJs
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.getPreferences
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.io.IOException
|
||||
|
||||
abstract class GreenShit(
|
||||
override val name: String,
|
||||
val url: String,
|
||||
override val lang: String,
|
||||
val scanId: Long = 1,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val isCi = System.getenv("CI") == "true"
|
||||
|
||||
private val preferences: SharedPreferences = getPreferences()
|
||||
|
||||
protected var apiUrl: String
|
||||
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
|
||||
private set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
|
||||
|
||||
private var restoreDefaultEnable: Boolean
|
||||
get() = preferences.getBoolean(DEFAULT_PREF, false)
|
||||
set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
|
||||
|
||||
override val baseUrl: String get() = when {
|
||||
isCi -> defaultBaseUrl
|
||||
else -> preferences.getString(BASE_URL_PREF, defaultBaseUrl)!!
|
||||
}
|
||||
|
||||
private val defaultBaseUrl: String = url
|
||||
private val defaultApiUrl: String = "https://api.sussytoons.wtf"
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::imageLocation)
|
||||
.build()
|
||||
|
||||
init {
|
||||
if (restoreDefaultEnable) {
|
||||
restoreDefaultEnable = false
|
||||
preferences.edit().putString(DEFAULT_BASE_URL_PREF, null).apply()
|
||||
preferences.edit().putString(API_DEFAULT_BASE_URL_PREF, null).apply()
|
||||
}
|
||||
|
||||
preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain ->
|
||||
if (domain != defaultBaseUrl) {
|
||||
preferences.edit()
|
||||
.putString(BASE_URL_PREF, defaultBaseUrl)
|
||||
.putString(DEFAULT_BASE_URL_PREF, defaultBaseUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
preferences.getString(API_DEFAULT_BASE_URL_PREF, null).let { domain ->
|
||||
if (domain != defaultApiUrl) {
|
||||
preferences.edit()
|
||||
.putString(API_BASE_URL_PREF, defaultApiUrl)
|
||||
.putString(API_DEFAULT_BASE_URL_PREF, defaultApiUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("scan-id", scanId.toString())
|
||||
|
||||
// ============================= Popular ==================================
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val json = response.parseScriptToJson().let(POPULAR_JSON_REGEX::find)
|
||||
?.groups?.get(1)?.value
|
||||
?: return MangasPage(emptyList(), false)
|
||||
val mangas = json.parseAs<ResultDto<List<MangaDto>>>().toSMangaList()
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
// ============================= Latest ===================================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/obras/novos-capitulos".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("pagina", page.toString())
|
||||
.addQueryParameter("limite", "24")
|
||||
.addQueryParameter("gen_id", "4")
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
|
||||
val mangas = dto.toSMangaList()
|
||||
return MangasPage(mangas, dto.hasNextPage())
|
||||
}
|
||||
|
||||
// ============================= Search ===================================
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$apiUrl/obras".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("obr_nome", query)
|
||||
.addQueryParameter("limite", "8")
|
||||
.addQueryParameter("pagina", page.toString())
|
||||
.addQueryParameter("todos_generos", "true")
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
|
||||
return MangasPage(dto.toSMangaList(), dto.hasNextPage())
|
||||
}
|
||||
|
||||
// ============================= Details ==================================
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
|
||||
?.groups?.get(0)?.value
|
||||
?: throw IOException("Details do mangá não foi encontrado")
|
||||
return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
|
||||
}
|
||||
|
||||
// ============================= Chapters =================================
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
|
||||
?.groups?.get(0)?.value
|
||||
?: return emptyList()
|
||||
return json.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList()
|
||||
}
|
||||
|
||||
// ============================= Pages ====================================
|
||||
|
||||
private val pageUrlSelector = "img.chakra-image"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
pageListParse(document).takeIf(List<Page>::isNotEmpty)?.let { return it }
|
||||
|
||||
val dto = extractScriptData(document)
|
||||
.let(::extractJsonContent)
|
||||
.let(::parseJsonToChapterPageDto)
|
||||
return dto.toPageList()
|
||||
}
|
||||
private fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(pageUrlSelector).mapIndexed { index, element ->
|
||||
Page(index, document.location(), element.absUrl("src"))
|
||||
}
|
||||
}
|
||||
private fun extractScriptData(document: Document): String {
|
||||
return document.select("script").map(Element::data)
|
||||
.firstOrNull(pageRegex::containsMatchIn)
|
||||
?: throw Exception("Failed to load pages: Script data not found")
|
||||
}
|
||||
|
||||
private fun extractJsonContent(scriptData: String): String {
|
||||
return pageRegex.find(scriptData)
|
||||
?.groups?.get(1)?.value
|
||||
?.let { "\"$it\"".parseAs<String>() }
|
||||
?: throw Exception("Failed to extract JSON from script")
|
||||
}
|
||||
|
||||
private fun parseJsonToChapterPageDto(jsonContent: String): ResultDto<ChapterPageDto> {
|
||||
return try {
|
||||
jsonContent.parseAs<ResultDto<ChapterPageDto>>()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to load pages: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun imageUrlRequest(page: Page): Request {
|
||||
val imageHeaders = headers.newBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
.build()
|
||||
return GET(page.url, imageHeaders)
|
||||
}
|
||||
|
||||
// ============================= Interceptors =================================
|
||||
|
||||
private fun imageLocation(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
response.close()
|
||||
|
||||
val url = request.url.newBuilder()
|
||||
.dropPathSegment(4)
|
||||
.build()
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.url(url)
|
||||
.build()
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
// ============================= Settings ====================================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val fields = listOf(
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = BASE_URL_PREF
|
||||
title = BASE_URL_PREF_TITLE
|
||||
summary = URL_PREF_SUMMARY
|
||||
|
||||
dialogTitle = BASE_URL_PREF_TITLE
|
||||
dialogMessage = "URL padrão:\n$defaultBaseUrl"
|
||||
|
||||
setDefaultValue(defaultBaseUrl)
|
||||
},
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = API_BASE_URL_PREF
|
||||
title = API_BASE_URL_PREF_TITLE
|
||||
summary = buildString {
|
||||
append("Se não souber como verificar a URL da API, ")
|
||||
append("busque suporte no Discord do repositório de extensões.")
|
||||
appendLine(URL_PREF_SUMMARY)
|
||||
append("\n⚠ A fonte não oferece suporte para essa extensão.")
|
||||
}
|
||||
|
||||
dialogTitle = BASE_URL_PREF_TITLE
|
||||
dialogMessage = "URL da API padrão:\n$defaultApiUrl"
|
||||
|
||||
setDefaultValue(defaultApiUrl)
|
||||
},
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = DEFAULT_PREF
|
||||
title = "Redefinir configurações"
|
||||
summary = buildString {
|
||||
append("Habilite para redefinir as configurações padrões no próximo reinicialização da aplicação.")
|
||||
appendLine("Você pode limpar os dados da extensão em Configurações > Avançado:")
|
||||
appendLine("\t - Limpar os cookies")
|
||||
appendLine("\t - Limpar os dados da WebView")
|
||||
appendLine("\t - Limpar o banco de dados (Procure a '$name' e remova os dados)")
|
||||
}
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fields.forEach(screen::addPreference)
|
||||
}
|
||||
|
||||
// ============================= Utilities ====================================
|
||||
|
||||
private fun Response.parseScriptToJson(): String {
|
||||
val document = asJsoup()
|
||||
val script = document.select("script")
|
||||
.map(Element::data)
|
||||
.filter(String::isNotEmpty)
|
||||
.joinToString("\n")
|
||||
|
||||
return QuickJs.create().use {
|
||||
it.evaluate(
|
||||
"""
|
||||
globalThis.self = globalThis;
|
||||
$script
|
||||
self.__next_f.map(it => it[it.length - 1]).join('')
|
||||
""".trimIndent(),
|
||||
) as String
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpUrl.Builder.dropPathSegment(count: Int): HttpUrl.Builder {
|
||||
repeat(count) {
|
||||
removePathSegment(0)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CDN_URL = "https://cdn.sussytoons.site"
|
||||
|
||||
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
|
||||
val POPULAR_JSON_REGEX = """(?:"dataTop":)(\{.+totalPaginas":\d+\})(?:.+"dataF)""".toRegex()
|
||||
val DETAILS_CHAPTER_REGEX = """(\{\"resultado.+"\}{3})""".toRegex()
|
||||
|
||||
private const val URL_PREF_SUMMARY = "Para uso temporário, se a extensão for atualizada, a alteração será perdida."
|
||||
|
||||
private const val BASE_URL_PREF = "overrideBaseUrl"
|
||||
private const val BASE_URL_PREF_TITLE = "Editar URL da fonte"
|
||||
private const val DEFAULT_BASE_URL_PREF = "defaultBaseUrl"
|
||||
private const val RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
|
||||
|
||||
private const val API_BASE_URL_PREF = "overrideApiUrl"
|
||||
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte"
|
||||
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl"
|
||||
|
||||
private const val DEFAULT_PREF = "defaultPref"
|
||||
}
|
||||
}
|
@ -1,11 +1,19 @@
|
||||
package eu.kanade.tachiyomi.extension.pt.sussyscan
|
||||
package eu.kanade.tachiyomi.multisrc.greenshit
|
||||
|
||||
import eu.kanade.tachiyomi.extension.pt.sussyscan.SussyToons.Companion.CDN_URL
|
||||
import android.annotation.SuppressLint
|
||||
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit.Companion.CDN_URL
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.Normalizer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class ResultDto<T>(
|
||||
@ -20,19 +28,60 @@ class ResultDto<T>(
|
||||
|
||||
fun hasNextPage() = currentPage < lastPage
|
||||
|
||||
fun toSMangaList() = (results as List<MangaDto>)
|
||||
.filterNot { it.slug.isNullOrBlank() }.map { it.toSManga() }
|
||||
}
|
||||
fun toSMangaList(): List<SManga> = (results as List<MangaDto>)
|
||||
.map { it.apply { slug = it.slug ?: name.createSlug() } }
|
||||
.map(MangaDto::toSManga)
|
||||
|
||||
@Serializable
|
||||
class WrapperDto(
|
||||
@SerialName("dataTop")
|
||||
val popular: ResultDto<List<MangaDto>>?,
|
||||
@JsonNames("atualizacoesInicial")
|
||||
private val dataLatest: ResultDto<List<MangaDto>>?,
|
||||
fun toSChapterList(): List<SChapter> = (results as WrapperChapterDto)
|
||||
.chapters.map {
|
||||
SChapter.create().apply {
|
||||
name = it.name
|
||||
CHAPTER_NUMBER_REGEX.find(it.name)?.groups?.get(0)?.value?.let {
|
||||
chapter_number = it.toFloat()
|
||||
}
|
||||
url = "/capitulo/${it.id}"
|
||||
date_upload = dateFormat.tryParse(it.updateAt)
|
||||
}
|
||||
}.sortedByDescending(SChapter::chapter_number)
|
||||
|
||||
) {
|
||||
val latest: ResultDto<List<MangaDto>> get() = dataLatest!!
|
||||
fun toPageList(): List<Page> {
|
||||
val dto = (results as ChapterPageDto)
|
||||
val chapter = dto.chapterNumber.let { number ->
|
||||
number.takeIf { it.isNotInteger() } ?: number.toInt()
|
||||
}
|
||||
return dto.pages.mapIndexed { index, image ->
|
||||
val imageUrl = when {
|
||||
image.isWordPressContent() -> {
|
||||
CDN_URL.toHttpUrl().newBuilder()
|
||||
.addPathSegments("wp-content/uploads/WP-manga/data")
|
||||
.addPathSegments(image.src.toPathSegment())
|
||||
.build()
|
||||
}
|
||||
else -> {
|
||||
"$CDN_URL/scans/${dto.manga.scanId}/obras/${dto.manga.id}/capitulos/$chapter/${image.src}"
|
||||
.toHttpUrl()
|
||||
}
|
||||
}
|
||||
Page(index, imageUrl = imageUrl.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.isNotInteger(): Boolean = toInt() < this
|
||||
|
||||
private fun String.createSlug(): String {
|
||||
return Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.trim()
|
||||
.replace("\\p{InCombiningDiacriticalMarks}+".toRegex(), "")
|
||||
.replace("\\p{Punct}".toRegex(), "")
|
||||
.replace("\\s+".toRegex(), "-")
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
|
||||
val CHAPTER_NUMBER_REGEX = """\d+(\.\d+)?""".toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@ -46,7 +95,7 @@ class MangaDto(
|
||||
@SerialName("obr_nome")
|
||||
val name: String,
|
||||
@SerialName("obr_slug")
|
||||
val slug: String?,
|
||||
var slug: String?,
|
||||
@SerialName("status")
|
||||
val status: MangaStatus,
|
||||
@SerialName("scan_id")
|
||||
@ -105,8 +154,6 @@ class ChapterDto(
|
||||
val id: Int,
|
||||
@SerialName("cap_nome")
|
||||
val name: String,
|
||||
@SerialName("cap_numero")
|
||||
val chapterNumber: Float?,
|
||||
@SerialName("cap_lancado_em")
|
||||
val updateAt: String,
|
||||
)
|
||||
@ -124,7 +171,7 @@ class ChapterPageDto(
|
||||
@SerialName("obra")
|
||||
val manga: MangaReferenceDto,
|
||||
@SerialName("cap_numero")
|
||||
val chapterNumber: Int,
|
||||
val chapterNumber: Float,
|
||||
) {
|
||||
@Serializable
|
||||
class MangaReferenceDto(
|
||||
@ -139,7 +186,16 @@ class ChapterPageDto(
|
||||
class PageDto(
|
||||
val src: String,
|
||||
@SerialName("numero")
|
||||
val number: Int? = null,
|
||||
val number: Float? = null,
|
||||
) {
|
||||
fun isWordPressContent(): Boolean = number == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes path segments:
|
||||
* Ex: [ "/a/b/", "/a/b", "a/b/", "a/b" ]
|
||||
* Result: "a/b"
|
||||
*/
|
||||
private fun String.toPathSegment() = this.trim().split("/")
|
||||
.filter(String::isNotEmpty)
|
||||
.joinToString("/")
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 31
|
||||
baseVersionCode = 33
|
||||
|
@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
@ -73,10 +74,10 @@ abstract class GroupLe(
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}", headers)
|
||||
GET("$baseUrl/list?sortType=rate&offset=${50 * (page - 1)}", headers)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}", headers)
|
||||
GET("$baseUrl/list?sortType=updated&offset=${50 * (page - 1)}", headers)
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
@ -103,15 +104,73 @@ abstract class GroupLe(
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url =
|
||||
"$baseUrl/search/advancedResults?offset=${50 * (page - 1)}".toHttpUrl()
|
||||
.newBuilder()
|
||||
val url = "$baseUrl/search/advancedResults?offset=${50 * (page - 1)}"
|
||||
.toHttpUrl()
|
||||
.newBuilder()
|
||||
|
||||
if (query.isNotEmpty()) {
|
||||
url.addQueryParameter("q", query)
|
||||
}
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreList -> filter.state.forEach { genre ->
|
||||
if (genre.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
|
||||
}
|
||||
}
|
||||
|
||||
is CategoryList -> filter.state.forEach { category ->
|
||||
if (category.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
|
||||
}
|
||||
}
|
||||
|
||||
is AgeList -> filter.state.forEach { age ->
|
||||
if (age.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(age.id, arrayOf("=", "=in", "=ex")[age.state])
|
||||
}
|
||||
}
|
||||
|
||||
is MoreList -> filter.state.forEach { more ->
|
||||
if (more.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(more.id, arrayOf("=", "=in", "=ex")[more.state])
|
||||
}
|
||||
}
|
||||
|
||||
is AdditionalFilterList -> filter.state.forEach { fils ->
|
||||
if (fils.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(fils.id, arrayOf("=", "=in", "=ex")[fils.state])
|
||||
}
|
||||
}
|
||||
|
||||
is OrderBy -> {
|
||||
url.addQueryParameter(
|
||||
"sortType",
|
||||
arrayOf("RATING", "POPULARITY", "YEAR", "NAME", "DATE_CREATE", "DATE_UPDATE", "USER_RATING")[filter.state],
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString().replace("=%3D", "="), headers)
|
||||
}
|
||||
|
||||
protected class OrderBy : Filter.Select<String>(
|
||||
"Сортировка",
|
||||
arrayOf("По популярности", "Популярно сейчас", "По году", "По алфавиту", "Новинки", "По дате обновления", "По рейтингу"),
|
||||
)
|
||||
|
||||
protected class Genre(name: String, val id: String) : Filter.TriState(name)
|
||||
|
||||
protected class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Жанры", genres)
|
||||
protected class CategoryList(categories: List<Genre>) : Filter.Group<Genre>("Категории", categories)
|
||||
protected class AgeList(ages: List<Genre>) : Filter.Group<Genre>("Возрастная рекомендация", ages)
|
||||
protected class MoreList(moren: List<Genre>) : Filter.Group<Genre>("Прочее", moren)
|
||||
protected class AdditionalFilterList(fils: List<Genre>) : Filter.Group<Genre>("Фильтры", fils)
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select(".expandable").first()!!
|
||||
val rawCategory = infoElement.select("span.elem_category").text()
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 8
|
||||
baseVersionCode = 9
|
||||
|
@ -127,7 +127,7 @@ abstract class Iken(
|
||||
val userId = userIdRegex.find(response.body.string())?.groupValues?.get(1) ?: ""
|
||||
|
||||
val id = response.request.url.fragment!!
|
||||
val chapterUrl = "$apiUrl/api/chapters?postId=$id&skip=0&take=1000&order=desc&userid=$userId"
|
||||
val chapterUrl = "$apiUrl/api/chapters?postId=$id&skip=0&take=900&order=desc&userid=$userId"
|
||||
val chapterResponse = client.newCall(GET(chapterUrl, headers)).execute()
|
||||
|
||||
val data = chapterResponse.parseAs<Post<ChapterListResponse>>()
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 20
|
||||
baseVersionCode = 21
|
||||
|
@ -2,11 +2,13 @@ package eu.kanade.tachiyomi.multisrc.kemono
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.double
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class KemonoFavouritesDto(
|
||||
val id: String,
|
||||
@ -25,7 +27,7 @@ class KemonoCreatorDto(
|
||||
) {
|
||||
var fav: Long = 0
|
||||
val updatedDate get() = when {
|
||||
updated.isString -> dateFormat.parse(updated.content)?.time ?: 0
|
||||
updated.isString -> dateFormat.tryParse(updated.content)
|
||||
else -> (updated.double * 1000).toLong()
|
||||
}
|
||||
|
||||
@ -62,7 +64,7 @@ class KemonoPostDto(
|
||||
private val service: String,
|
||||
private val user: String,
|
||||
private val title: String,
|
||||
private val added: String,
|
||||
private val added: String?,
|
||||
private val published: String?,
|
||||
private val edited: String?,
|
||||
private val file: KemonoFileDto,
|
||||
@ -80,13 +82,13 @@ class KemonoPostDto(
|
||||
}.distinctBy { it.path }.map { it.toString() }
|
||||
|
||||
fun toSChapter() = SChapter.create().apply {
|
||||
val postDate = dateFormat.parse(edited ?: published ?: added)
|
||||
val postDate = dateFormat.tryParse(edited ?: published ?: added)
|
||||
|
||||
url = "/$service/user/$user/post/$id"
|
||||
date_upload = postDate?.time ?: 0
|
||||
date_upload = postDate
|
||||
name = title.ifBlank {
|
||||
val postDateString = when {
|
||||
postDate != null && postDate.time != 0L -> chapterNameDateFormat.format(postDate)
|
||||
postDate != 0L -> chapterNameDateFormat.format(postDate)
|
||||
else -> "unknown date"
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 15
|
||||
baseVersionCode = 17
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:i18n"))
|
||||
|
@ -59,8 +59,16 @@ abstract class Keyoapp(
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers)
|
||||
|
||||
override fun popularMangaSelector(): String =
|
||||
"div.flex-col div.grid > div.group.border, div:has(h2:contains(Trending)) + div .group.overflow-hidden.grid"
|
||||
open val popularMangaTitleSelector = listOf(
|
||||
"Popular",
|
||||
"Popularie",
|
||||
"Trending",
|
||||
)
|
||||
|
||||
override fun popularMangaSelector(): String = selector(
|
||||
"div:contains(%s) + div .group.overflow-hidden.grid",
|
||||
popularMangaTitleSelector,
|
||||
)
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
thumbnail_url = element.getImageUrl("*[style*=background-image]")
|
||||
@ -243,7 +251,7 @@ abstract class Keyoapp(
|
||||
|
||||
override fun chapterListSelector(): String {
|
||||
if (!preferences.showPaidChapters) {
|
||||
return "#chapters > a:not(:has(.text-sm span:matches(Upcoming))):not(:has(img[src*=Coin.svg]))"
|
||||
return "#chapters > a:not(:has(.text-sm span:matches(Upcoming))):not(:has(img[alt~=Coin]))"
|
||||
}
|
||||
return "#chapters > a:not(:has(.text-sm span:matches(Upcoming)))"
|
||||
}
|
||||
@ -357,6 +365,10 @@ abstract class Keyoapp(
|
||||
return now.timeInMillis
|
||||
}
|
||||
|
||||
private fun selector(selector: String, contains: List<String>): String {
|
||||
return contains.joinToString { selector.replace("%s", it) }
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_PAID_CHAPTERS_PREF
|
||||
|
@ -2,7 +2,7 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 41
|
||||
baseVersionCode = 42
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:cryptoaes"))
|
||||
|
@ -245,11 +245,13 @@ abstract class Madara(
|
||||
val mangaUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment(mangaSubString)
|
||||
addPathSegment(query.substringAfter(URL_SEARCH_PREFIX))
|
||||
addPathSegment("") // add trailing slash
|
||||
}.build()
|
||||
return client.newCall(GET(mangaUrl, headers))
|
||||
.asObservableSuccess().map { response ->
|
||||
val manga = mangaDetailsParse(response).apply {
|
||||
setUrlWithoutDomain(mangaUrl.toString())
|
||||
initialized = true
|
||||
}
|
||||
|
||||
MangasPage(listOf(manga), false)
|
||||
@ -977,6 +979,8 @@ abstract class Madara(
|
||||
open val pageListParseSelector = "div.page-break, li.blocks-gallery-item, .reading-content .text-left:not(:has(.blocks-gallery-item)) img"
|
||||
|
||||
open val chapterProtectorSelector = "#chapter-protector-data"
|
||||
open val chapterProtectorPasswordPrefix = "wpmangaprotectornonce='"
|
||||
open val chapterProtectorDataPrefix = "chapter_data='"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
launchIO { countViews(document) }
|
||||
@ -992,11 +996,11 @@ abstract class Madara(
|
||||
?.let { Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) }
|
||||
?: chapterProtector.html()
|
||||
val password = chapterProtectorHtml
|
||||
.substringAfter("wpmangaprotectornonce='")
|
||||
.substringAfter(chapterProtectorPasswordPrefix)
|
||||
.substringBefore("';")
|
||||
val chapterData = json.parseToJsonElement(
|
||||
chapterProtectorHtml
|
||||
.substringAfter("chapter_data='")
|
||||
.substringAfter(chapterProtectorDataPrefix)
|
||||
.substringBefore("';")
|
||||
.replace("\\/", "/"),
|
||||
).jsonObject
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 6
|
||||
baseVersionCode = 7
|
||||
|
@ -85,6 +85,9 @@ abstract class MangaBox(
|
||||
|
||||
private fun useAltCdnInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (cdnSet.isEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val requestTag = request.tag(MangaBoxFallBackTag::class.java)
|
||||
val originalResponse: Response? = try {
|
||||
chain.proceed(request)
|
||||
@ -346,11 +349,10 @@ abstract class MangaBox(
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val element = document.select("head > script").lastOrNull()
|
||||
?: return emptyList()
|
||||
val content = document.select("script:containsData(cdns =)").joinToString("\n") { it.data() }
|
||||
val cdns =
|
||||
extractArray(element.html(), "cdns") + extractArray(element.html(), "backupImage")
|
||||
val chapterImages = extractArray(element.html(), "chapterImages")
|
||||
extractArray(content, "cdns") + extractArray(content, "backupImage")
|
||||
val chapterImages = extractArray(content, "chapterImages")
|
||||
|
||||
// Add all parsed cdns to set
|
||||
cdnSet.addAll(cdns)
|
||||
@ -369,6 +371,10 @@ abstract class MangaBox(
|
||||
}
|
||||
|
||||
Page(i, document.location(), parsedUrl)
|
||||
}.ifEmpty {
|
||||
document.select("div.container-chapter-reader > img").mapIndexed { i, img ->
|
||||
Page(i, imageUrl = img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,9 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 30
|
||||
baseVersionCode = 34
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:randomua"))
|
||||
//noinspection UseTomlInstead
|
||||
implementation("org.brotli:dec:0.1.2")
|
||||
}
|
||||
|
@ -1,62 +1,71 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
|
||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.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 eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.brotli.dec.BrotliInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.text.ParseException
|
||||
import java.net.URLEncoder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
abstract class MangaHub(
|
||||
override val name: String,
|
||||
final override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val mangaSource: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US),
|
||||
) : ParsedHttpSource() {
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH),
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private var baseApiUrl = "https://api.mghcdn.com"
|
||||
private var baseCdnUrl = "https://imgx.mghcdn.com"
|
||||
private val regex = Regex("mhub_access=([^;]+)")
|
||||
private val baseApiUrl = "https://api.mghcdn.com"
|
||||
private val baseCdnUrl = "https://imgx.mghcdn.com"
|
||||
private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
|
||||
private val apiRegex = Regex("mhub_access=([^;]+)")
|
||||
private val spaceRegex = Regex("\\s+")
|
||||
private val apiErrorRegex = Regex("""rate\s*limit|api\s*key""")
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
private fun SharedPreferences.getUseGenericTitlePref(): Boolean = getBoolean(
|
||||
PREF_USE_GENERIC_TITLE,
|
||||
false,
|
||||
)
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.setRandomUserAgent(
|
||||
userAgentType = UserAgentType.DESKTOP,
|
||||
filterInclude = listOf("chrome"),
|
||||
)
|
||||
.addInterceptor(::apiAuthInterceptor)
|
||||
.rateLimit(1)
|
||||
.addNetworkInterceptor(::compatEncodingInterceptor)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
@ -69,60 +78,158 @@ abstract class MangaHub(
|
||||
.add("Sec-Fetch-Site", "same-origin")
|
||||
.add("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
open val json: Json by injectLazy()
|
||||
private fun postRequestGraphQL(query: String, refreshUrl: String? = null): Request {
|
||||
val requestHeaders = headersBuilder()
|
||||
.set("Accept", "application/json")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Origin", baseUrl)
|
||||
.set("Sec-Fetch-Dest", "empty")
|
||||
.set("Sec-Fetch-Mode", "cors")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.removeAll("Upgrade-Insecure-Requests")
|
||||
.build()
|
||||
|
||||
val body = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
|
||||
return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody())
|
||||
.newBuilder()
|
||||
.tag(GraphQLTag::class.java, GraphQLTag(refreshUrl))
|
||||
.build()
|
||||
}
|
||||
|
||||
// Normally this gets handled properly but in older forks such as TachiyomiJ2K, we have to manually intercept it
|
||||
// as they have an outdated implementation of NetworkHelper.
|
||||
private fun compatEncodingInterceptor(chain: Interceptor.Chain): Response {
|
||||
var response = chain.proceed(chain.request())
|
||||
val contentEncoding = response.header("Content-Encoding")
|
||||
|
||||
if (contentEncoding == "gzip") {
|
||||
val parsedBody = response.body.byteStream().let { gzipInputStream ->
|
||||
GZIPInputStream(gzipInputStream).use { inputStream ->
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
response = response.createNewWithCompatBody(parsedBody)
|
||||
} else if (contentEncoding == "br") {
|
||||
val parsedBody = response.body.byteStream().let { brotliInputStream ->
|
||||
BrotliInputStream(brotliInputStream).use { inputStream ->
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
response = response.createNewWithCompatBody(parsedBody)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Response.createNewWithCompatBody(outputStream: ByteArray): Response {
|
||||
return this.newBuilder()
|
||||
.body(outputStream.toResponseBody(this.body.contentType()))
|
||||
.removeHeader("Content-Encoding")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val request = chain.request()
|
||||
val tag = request.tag(GraphQLTag::class.java)
|
||||
?: return chain.proceed(request) // We won't intercept non-graphql requests (like image retrieval)
|
||||
|
||||
return try {
|
||||
tryApiRequest(chain, request)
|
||||
} catch (e: Throwable) {
|
||||
val noCookie = e is MangaHubCookieNotFound
|
||||
val apiError = e is ApiErrorException &&
|
||||
apiErrorRegex.containsMatchIn(e.message ?: "")
|
||||
|
||||
if (noCookie || apiError) {
|
||||
refreshApiKey(tag.refreshUrl)
|
||||
tryApiRequest(chain, request)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryApiRequest(chain: Interceptor.Chain, request: Request): Response {
|
||||
val cookie = client.cookieJar
|
||||
.loadForRequest(baseUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
|
||||
?: throw MangaHubCookieNotFound()
|
||||
|
||||
val request =
|
||||
if (originalRequest.url.toString() == "$baseApiUrl/graphql" && cookie != null) {
|
||||
originalRequest.newBuilder()
|
||||
.header("x-mhub-access", cookie.value)
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
}
|
||||
val apiRequest = request.newBuilder()
|
||||
.header("x-mhub-access", cookie.value)
|
||||
.build()
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val response = chain.proceed(apiRequest)
|
||||
|
||||
private fun refreshApiKey(chapter: SChapter) {
|
||||
val slug = "$baseUrl${chapter.url}"
|
||||
.toHttpUrlOrNull()
|
||||
?.pathSegments
|
||||
?.get(1)
|
||||
val apiResponse = response.peekBody(Long.MAX_VALUE).string()
|
||||
.parseAs<ApiResponseError>()
|
||||
|
||||
val url = if (slug != null) {
|
||||
"$baseUrl/manga/$slug".toHttpUrl()
|
||||
} else {
|
||||
baseUrl.toHttpUrl()
|
||||
if (apiResponse.errors != null) {
|
||||
response.close() // Avoid leaks
|
||||
val errors = apiResponse.errors.joinToString("\n") { it.message }
|
||||
throw ApiErrorException(errors)
|
||||
}
|
||||
|
||||
val oldKey = client.cookieJar
|
||||
.loadForRequest(baseUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
|
||||
return response
|
||||
}
|
||||
|
||||
for (i in 1..2) {
|
||||
// Clear key cookie
|
||||
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
|
||||
client.cookieJar.saveFromResponse(url, listOf(cookie))
|
||||
private class MangaHubCookieNotFound : IOException("mhub_access cookie not found")
|
||||
private class ApiErrorException(errorMessage: String) : IOException(errorMessage)
|
||||
|
||||
// We try requesting again with param if the first one fails
|
||||
val query = if (i == 2) "?reloadKey=1" else ""
|
||||
private val lock = ReentrantLock()
|
||||
private var refreshed = 0L
|
||||
|
||||
try {
|
||||
val response = client.newCall(GET("$url$query", headers)).execute()
|
||||
val returnedKey = response.headers["set-cookie"]?.let { regex.find(it)?.groupValues?.get(1) }
|
||||
response.close() // Avoid potential resource leaks
|
||||
private fun refreshApiKey(refreshUrl: String? = null) {
|
||||
if (refreshed + 10000 < System.currentTimeMillis() && lock.tryLock()) {
|
||||
val url = when {
|
||||
refreshUrl != null -> refreshUrl
|
||||
else -> "$baseUrl/chapter/martial-peak/chapter-${Random.nextInt(1000, 3000)}"
|
||||
}.toHttpUrl()
|
||||
|
||||
if (returnedKey != oldKey) break; // Break out of loop since we got an allegedly valid API key
|
||||
} catch (_: IOException) {
|
||||
throw IOException("An error occurred while obtaining a new API key") // Show error
|
||||
val oldKey = client.cookieJar
|
||||
.loadForRequest(baseUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
|
||||
|
||||
for (i in 1..2) {
|
||||
// Clear key cookie
|
||||
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
|
||||
client.cookieJar.saveFromResponse(url, listOf(cookie))
|
||||
|
||||
try {
|
||||
// We try requesting again with param if the first one fails
|
||||
val query = if (i == 2) "?reloadKey=1" else ""
|
||||
val response = client.newCall(
|
||||
GET(
|
||||
"$url$query",
|
||||
headers.newBuilder()
|
||||
.set("Referer", "$baseUrl/manga/${url.pathSegments[1]}")
|
||||
.build(),
|
||||
),
|
||||
).execute()
|
||||
val returnedKey =
|
||||
response.headers["set-cookie"]?.let { apiRegex.find(it)?.groupValues?.get(1) }
|
||||
response.close() // Avoid potential resource leaks
|
||||
|
||||
if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key
|
||||
} catch (_: Throwable) {
|
||||
lock.unlock()
|
||||
throw Exception("An error occurred while obtaining a new API key") // Show error
|
||||
}
|
||||
}
|
||||
refreshed = System.currentTimeMillis()
|
||||
lock.unlock()
|
||||
} else {
|
||||
lock.lock() // wait here until lock is released
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,35 +240,36 @@ abstract class MangaHub(
|
||||
val signature: String,
|
||||
)
|
||||
|
||||
private fun Element.toSignature(): String {
|
||||
val author = this.select("small").text()
|
||||
val chNum = this.select(".col-sm-6 a:contains(#)").text()
|
||||
val genres = this.select(".genre-label").joinToString { it.text() }
|
||||
private fun ApiMangaSearchItem.toSignature(): String {
|
||||
val author = this.author
|
||||
val chNum = this.latestChapter
|
||||
val genres = this.genres
|
||||
|
||||
return author + chNum + genres
|
||||
}
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/popular/page/$page", headers)
|
||||
private fun mangaRequest(page: Int, order: String): Request {
|
||||
return postRequestGraphQL(searchQuery(mangaSource, "", "all", order, page))
|
||||
}
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, "POPULAR")
|
||||
|
||||
// often enough there will be nearly identical entries with slightly different
|
||||
// titles, URLs, and image names. in order to cut these "duplicates" down,
|
||||
// assign a "signature" based on author name, chapter number, and genres
|
||||
// if all of those are the same, then it it's the same manga
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
val mangaList = response.parseAs<ApiSearchResponse>()
|
||||
|
||||
val mangas = doc.select(popularMangaSelector())
|
||||
.map {
|
||||
SMangaDTO(
|
||||
it.select("h4 a").attr("abs:href"),
|
||||
it.select("h4 a").text(),
|
||||
it.select("img").attr("abs:src"),
|
||||
it.toSignature(),
|
||||
)
|
||||
}
|
||||
val mangas = mangaList.data.search.rows.map {
|
||||
SMangaDTO(
|
||||
"$baseUrl/manga/${it.slug}",
|
||||
it.title,
|
||||
"$baseThumbCdnUrl/${it.image}",
|
||||
it.toSignature(),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.signature }
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
@ -170,221 +278,171 @@ abstract class MangaHub(
|
||||
thumbnail_url = it.thumbnailUrl
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, doc.select(popularMangaNextPageSelector()).isNotEmpty())
|
||||
|
||||
// Entries have a max of 30 per request
|
||||
return MangasPage(mangas, mangaList.data.search.rows.count() == 30)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = ".col-sm-6:not(:has(a:contains(Yaoi)))"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "ul.pager li.next > a"
|
||||
|
||||
// latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/updates/page/$page", headers)
|
||||
return mangaRequest(page, "LATEST")
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
return popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search/page/$page".toHttpUrl().newBuilder()
|
||||
url.addQueryParameter("q", query)
|
||||
var order = "POPULAR"
|
||||
var genres = "all"
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is OrderBy -> {
|
||||
val order = filter.values[filter.state]
|
||||
url.addQueryParameter("order", order.key)
|
||||
order = filter.values[filter.state].key
|
||||
}
|
||||
is GenreList -> {
|
||||
val genre = filter.values[filter.state]
|
||||
url.addQueryParameter("genre", genre.key)
|
||||
genres = filter.included.joinToString(",").takeIf { it.isNotBlank() } ?: "all"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
return postRequestGraphQL(searchQuery(mangaSource, query, genres, order, page))
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
return popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = document.select(".breadcrumb .active span").text()
|
||||
manga.author = document.select("div:has(h1) span:contains(Author) + span").first()?.text()
|
||||
manga.artist = document.select("div:has(h1) span:contains(Artist) + span").first()?.text()
|
||||
manga.genre = document.select(".row p a").joinToString { it.text() }
|
||||
manga.description = document.select(".tab-content p").first()?.text()
|
||||
manga.thumbnail_url = document.select("img.img-responsive").first()
|
||||
?.attr("src")
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return postRequestGraphQL(
|
||||
mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/")),
|
||||
refreshUrl = "$baseUrl${manga.url}",
|
||||
)
|
||||
}
|
||||
|
||||
document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText ->
|
||||
when {
|
||||
statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING
|
||||
statusText.contains("completed", true) -> manga.status = SManga.COMPLETED
|
||||
else -> manga.status = SManga.UNKNOWN
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val rawManga = response.parseAs<ApiMangaDetailsResponse>()
|
||||
|
||||
return SManga.create().apply {
|
||||
title = rawManga.data.manga.title!!
|
||||
author = rawManga.data.manga.author
|
||||
artist = rawManga.data.manga.artist
|
||||
genre = rawManga.data.manga.genres
|
||||
thumbnail_url = "$baseThumbCdnUrl/${rawManga.data.manga.image}"
|
||||
status = when (rawManga.data.manga.status) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// add alternative name to manga description
|
||||
document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName ->
|
||||
if (alternativeName.isNotBlank()) {
|
||||
manga.description = manga.description.orEmpty().let {
|
||||
if (it.isBlank()) {
|
||||
"Alternative Name: $alternativeName"
|
||||
} else {
|
||||
"$it\n\nAlternative Name: $alternativeName"
|
||||
}
|
||||
description = buildString {
|
||||
rawManga.data.manga.description?.let(::append)
|
||||
|
||||
// Add alternative title
|
||||
val altTitle = rawManga.data.manga.alternativeTitle
|
||||
if (!altTitle.isNullOrBlank()) {
|
||||
if (isNotBlank()) append("\n\n")
|
||||
append("Alternative Name: $altTitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
// chapters
|
||||
override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}"
|
||||
|
||||
// Chapters
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return postRequestGraphQL(
|
||||
mangaChapterListQuery(mangaSource, manga.url.removePrefix("/manga/")),
|
||||
refreshUrl = "$baseUrl${manga.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val head = document.head()
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it, head) }
|
||||
val chapterList = response.parseAs<ApiMangaDetailsResponse>()
|
||||
val useGenericTitle = preferences.getUseGenericTitlePref()
|
||||
|
||||
return chapterList.data.manga.chapters!!.map {
|
||||
SChapter.create().apply {
|
||||
val numberString = "${if (it.number % 1 == 0f) it.number.toInt() else it.number}"
|
||||
|
||||
name = if (!useGenericTitle) {
|
||||
generateChapterName(it.title.trim().replace(spaceRegex, " "), numberString)
|
||||
} else {
|
||||
generateGenericChapterName(numberString)
|
||||
}
|
||||
|
||||
url = "/${chapterList.data.manga.slug}/chapter-${it.number}"
|
||||
chapter_number = it.number
|
||||
date_upload = dateFormat.tryParse(it.date)
|
||||
}
|
||||
}.reversed() // The response is sorted in ASC format so we need to reverse it
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".tab-content ul li"
|
||||
|
||||
private fun chapterFromElement(element: Element, head: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
val potentialLinks = element.select("a[href*='$baseUrl/chapter/']:not([rel*=nofollow]):not([rel*=noreferrer])")
|
||||
var visibleLink = ""
|
||||
potentialLinks.forEach { a ->
|
||||
val className = a.className()
|
||||
val styles = head.select("style").html()
|
||||
if (!styles.contains(".$className { display:none; }")) {
|
||||
visibleLink = a.attr("href")
|
||||
return@forEach
|
||||
}
|
||||
private fun generateChapterName(title: String, number: String): String {
|
||||
return if (title.contains(number)) {
|
||||
title
|
||||
} else if (title.isNotBlank()) {
|
||||
"Chapter $number - $title"
|
||||
} else {
|
||||
generateGenericChapterName(number)
|
||||
}
|
||||
chapter.setUrlWithoutDomain(visibleLink)
|
||||
chapter.name = chapter.url.trimEnd('/').substringAfterLast('/').replace('-', ' ')
|
||||
chapter.date_upload = element.select("small.UovLc").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
throw UnsupportedOperationException()
|
||||
private fun generateGenericChapterName(number: String): String {
|
||||
return "Chapter $number"
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val now = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
var parsedDate = 0L
|
||||
when {
|
||||
"just now" in date || "less than an hour" in date -> {
|
||||
parsedDate = now.timeInMillis
|
||||
}
|
||||
// parses: "1 hour ago" and "2 hours ago"
|
||||
"hour" in date -> {
|
||||
val hours = date.replaceAfter(" ", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.HOUR, -hours) }.timeInMillis
|
||||
}
|
||||
// parses: "Yesterday" and "2 days ago"
|
||||
"day" in date -> {
|
||||
val days = date.replace("days ago", "").trim().toIntOrNull() ?: 1
|
||||
parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -days) }.timeInMillis
|
||||
}
|
||||
// parses: "2 weeks ago"
|
||||
"weeks" in date -> {
|
||||
val weeks = date.replace("weeks ago", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -weeks) }.timeInMillis
|
||||
}
|
||||
// parses: "12-20-2019" and defaults everything that wasn't taken into account to 0
|
||||
else -> {
|
||||
try {
|
||||
parsedDate = dateFormat.parse(date)?.time ?: 0L
|
||||
} catch (e: ParseException) { /*nothing to do, parsedDate is initialized with 0L*/ }
|
||||
}
|
||||
}
|
||||
return parsedDate
|
||||
}
|
||||
override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/chapter${chapter.url}"
|
||||
|
||||
// pages
|
||||
// Pages
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val body = buildJsonObject {
|
||||
put("query", PAGES_QUERY)
|
||||
put(
|
||||
"variables",
|
||||
buildJsonObject {
|
||||
val chapterUrl = chapter.url.split("/")
|
||||
val chapterUrl = chapter.url.split("/")
|
||||
|
||||
put("mangaSource", mangaSource)
|
||||
put("slug", chapterUrl[2])
|
||||
put("number", chapterUrl[3].substringAfter("-").toFloat())
|
||||
},
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
.toRequestBody()
|
||||
return postRequestGraphQL(
|
||||
pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()),
|
||||
refreshUrl = "$baseUrl/chapter${chapter.url}",
|
||||
)
|
||||
}
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Accept", "application/json")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Origin", baseUrl)
|
||||
.set("Sec-Fetch-Dest", "empty")
|
||||
.set("Sec-Fetch-Mode", "cors")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.removeAll("Upgrade-Insecure-Requests")
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chapterObject = response.parseAs<ApiChapterPagesResponse>()
|
||||
val pages = chapterObject.data.chapter.pages.parseAs<ApiChapterPages>()
|
||||
|
||||
// We'll update the cookie here to match the browser's "recently" opened chapter.
|
||||
// This mimics how the browser works and gives us more chance to receive a valid API key upon refresh
|
||||
val now = Calendar.getInstance().time.time
|
||||
val baseHttpUrl = baseUrl.toHttpUrl()
|
||||
val recently = buildJsonObject {
|
||||
putJsonObject((now).toString()) {
|
||||
put("mangaID", chapterObject.data.chapter.mangaID)
|
||||
put("number", chapterObject.data.chapter.chapterNumber)
|
||||
}
|
||||
}.toString()
|
||||
|
||||
val recentlyCookie = Cookie.Builder()
|
||||
.domain(baseHttpUrl.host)
|
||||
.name("recently")
|
||||
.value(URLEncoder.encode(recently, "utf-8"))
|
||||
.expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months
|
||||
.build()
|
||||
|
||||
return POST("$baseApiUrl/graphql", newHeaders, body)
|
||||
}
|
||||
// Add/update the cookie
|
||||
client.cookieJar.saveFromResponse(baseHttpUrl, listOf(recentlyCookie))
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||
super.fetchPageList(chapter)
|
||||
.doOnError { refreshApiKey(chapter) }
|
||||
.retry(1)
|
||||
// We'll log our action to the site to further increase the chance of valid API key
|
||||
val ipRequest = client.newCall(GET("https://api.ipify.org?format=json")).execute()
|
||||
val ip = ipRequest.parseAs<PublicIPResponse>().ip
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chapterObject = json.decodeFromString<ApiChapterPagesResponse>(response.body.string())
|
||||
client.newCall(GET("$baseUrl/action/logHistory2/${chapterObject.data.chapter.manga.slug}/${chapterObject.data.chapter.chapterNumber}?browserID=$ip")).execute().close()
|
||||
ipRequest.close()
|
||||
|
||||
if (chapterObject.data?.chapter == null) {
|
||||
if (chapterObject.errors != null) {
|
||||
val errors = chapterObject.errors.joinToString("\n") { it.message }
|
||||
throw Exception(errors)
|
||||
}
|
||||
throw Exception("Unknown error while processing pages")
|
||||
}
|
||||
|
||||
val pages = json.decodeFromString<ApiChapterPages>(chapterObject.data.chapter.pages)
|
||||
|
||||
return pages.i.mapIndexed { i, page ->
|
||||
Page(i, "", "$baseCdnUrl/${pages.p}$page")
|
||||
return pages.images.mapIndexed { i, page ->
|
||||
Page(i, "", "$baseCdnUrl/${pages.page}$page")
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,10 +459,14 @@ abstract class MangaHub(
|
||||
return GET(page.url, newHeaders)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
|
||||
// filters
|
||||
private class Genre(title: String, val key: String) : Filter.TriState(title) {
|
||||
private class Genre(title: String, val key: String) : Filter.CheckBox(title) {
|
||||
fun getGenreKey(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
@ -417,11 +479,14 @@ abstract class MangaHub(
|
||||
}
|
||||
|
||||
private class OrderBy(orders: Array<Order>) : Filter.Select<Order>("Order", orders, 0)
|
||||
private class GenreList(genres: Array<Genre>) : Filter.Select<Genre>("Genres", genres, 0)
|
||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres) {
|
||||
val included: List<String>
|
||||
get() = state.filter { it.state }.map { it.getGenreKey() }
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
OrderBy(orderBy),
|
||||
GenreList(genres),
|
||||
OrderBy(orderBy),
|
||||
)
|
||||
|
||||
private val orderBy = arrayOf(
|
||||
@ -432,70 +497,119 @@ abstract class MangaHub(
|
||||
Order("Completed", "COMPLETED"),
|
||||
)
|
||||
|
||||
private val genres = arrayOf(
|
||||
Genre("All Genres", "all"),
|
||||
Genre("[no chapters]", "no-chapters"),
|
||||
Genre("4-Koma", "4-koma"),
|
||||
private val genres = listOf(
|
||||
Genre("Action", "action"),
|
||||
Genre("Adventure", "adventure"),
|
||||
Genre("Award Winning", "award-winning"),
|
||||
Genre("Comedy", "comedy"),
|
||||
Genre("Cooking", "cooking"),
|
||||
Genre("Crime", "crime"),
|
||||
Genre("Demons", "demons"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Adult", "adult"),
|
||||
Genre("Drama", "drama"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Food", "food"),
|
||||
Genre("Game", "game"),
|
||||
Genre("Gender bender", "gender-bender"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Historical", "historical"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Isekai", "isekai"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Kids", "kids"),
|
||||
Genre("Magic", "magic"),
|
||||
Genre("Magical Girls", "magical-girls"),
|
||||
Genre("Manhua", "manhua"),
|
||||
Genre("Martial Arts", "martial-arts"),
|
||||
Genre("Romance", "romance"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Supernatural", "supernatural"),
|
||||
Genre("Webtoons", "webtoons"),
|
||||
Genre("Manhwa", "manhwa"),
|
||||
Genre("Martial arts", "martial-arts"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Manhua", "manhua"),
|
||||
Genre("Mature", "mature"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Sports", "sports"),
|
||||
Genre("School Life", "school-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Mystery", "mystery"),
|
||||
Genre("Psychological", "psychological"),
|
||||
Genre("Shounen ai", "shounen-ai"),
|
||||
Genre("Slice of life", "slice-of-life"),
|
||||
Genre("Shoujo ai", "shoujo-ai"),
|
||||
Genre("Cooking", "cooking"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Tragedy", "tragedy"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Sci-Fi", "sci-fi"),
|
||||
Genre("Yuri", "yuri"),
|
||||
Genre("Yaoi", "yaoi"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Gender bender", "gender-bender"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Mecha", "mecha"),
|
||||
Genre("Medical", "medical"),
|
||||
Genre("Military", "military"),
|
||||
Genre("Magic", "magic"),
|
||||
Genre("4-Koma", "4-koma"),
|
||||
Genre("Music", "music"),
|
||||
Genre("Mystery", "mystery"),
|
||||
Genre("One shot", "one-shot"),
|
||||
Genre("Oneshot", "oneshot"),
|
||||
Genre("Parody", "parody"),
|
||||
Genre("Police", "police"),
|
||||
Genre("Psychological", "psychological"),
|
||||
Genre("Romance", "romance"),
|
||||
Genre("School life", "school-life"),
|
||||
Genre("Sci fi", "sci-fi"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Shotacon", "shotacon"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Shoujo ai", "shoujo-ai"),
|
||||
Genre("Shoujoai", "shoujoai"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Shounen ai", "shounen-ai"),
|
||||
Genre("Shounenai", "shounenai"),
|
||||
Genre("Slice of life", "slice-of-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Space", "space"),
|
||||
Genre("Sports", "sports"),
|
||||
Genre("Super Power", "super-power"),
|
||||
Genre("Superhero", "superhero"),
|
||||
Genre("Supernatural", "supernatural"),
|
||||
Genre("Thriller", "thriller"),
|
||||
Genre("Tragedy", "tragedy"),
|
||||
Genre("Vampire", "vampire"),
|
||||
Genre("Webtoon", "webtoon"),
|
||||
Genre("Webtoons", "webtoons"),
|
||||
Genre("Isekai", "isekai"),
|
||||
Genre("Game", "game"),
|
||||
Genre("Award Winning", "award-winning"),
|
||||
Genre("Oneshot", "oneshot"),
|
||||
Genre("Demons", "demons"),
|
||||
Genre("Military", "military"),
|
||||
Genre("Police", "police"),
|
||||
Genre("Super Power", "super-power"),
|
||||
Genre("Food", "food"),
|
||||
Genre("Kids", "kids"),
|
||||
Genre("Magical Girls", "magical-girls"),
|
||||
Genre("Wuxia", "wuxia"),
|
||||
Genre("Yuri", "yuri"),
|
||||
)
|
||||
Genre("Superhero", "superhero"),
|
||||
Genre("Thriller", "thriller"),
|
||||
Genre("Crime", "crime"),
|
||||
Genre("Philosophical", "philosophical"),
|
||||
Genre("Adaptation", "adaptation"),
|
||||
Genre("Full Color", "full-color"),
|
||||
Genre("Crossdressing", "crossdressing"),
|
||||
Genre("Reincarnation", "reincarnation"),
|
||||
Genre("Manga", "manga"),
|
||||
Genre("Cartoon", "cartoon"),
|
||||
Genre("Survival", "survival"),
|
||||
Genre("Comic", "comic"),
|
||||
Genre("English", "english"),
|
||||
Genre("Harlequin", "harlequin"),
|
||||
Genre("Time Travel", "time-travel"),
|
||||
Genre("Traditional Games", "traditional-games"),
|
||||
Genre("Reverse Harem", "reverse-harem"),
|
||||
Genre("Animals", "animals"),
|
||||
Genre("Aliens", "aliens"),
|
||||
Genre("Loli", "loli"),
|
||||
Genre("Video Games", "video-games"),
|
||||
Genre("Monsters", "monsters"),
|
||||
Genre("Office Workers", "office-workers"),
|
||||
Genre("System", "system"),
|
||||
Genre("Villainess", "villainess"),
|
||||
Genre("Zombies", "zombies"),
|
||||
Genre("Vampires", "vampires"),
|
||||
Genre("Violence", "violence"),
|
||||
Genre("Monster Girls", "monster-girls"),
|
||||
Genre("Anthology", "anthology"),
|
||||
Genre("Ghosts", "ghosts"),
|
||||
Genre("Delinquents", "delinquents"),
|
||||
Genre("Post-Apocalyptic", "post-apocalyptic"),
|
||||
Genre("Xianxia", "xianxia"),
|
||||
Genre("Xuanhuan", "xuanhuan"),
|
||||
Genre("R-18", "r-18"),
|
||||
Genre("Cultivation", "cultivation"),
|
||||
Genre("Rebirth", "rebirth"),
|
||||
Genre("Gore", "gore"),
|
||||
Genre("Russian", "russian"),
|
||||
Genre("Samurai", "samurai"),
|
||||
Genre("Ninja", "ninja"),
|
||||
Genre("Revenge", "revenge"),
|
||||
Genre("Cheat Systems", "cheat-systems"),
|
||||
Genre("Dungeons", "dungeons"),
|
||||
Genre("Overpowered", "overpowered"),
|
||||
).sortedBy { it.toString() }
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_USE_GENERIC_TITLE
|
||||
title = "Use generic title"
|
||||
summary = "Use generic chapter title (\"Chapter 'x'\") instead of the given one.\nNote: May require manga entry to be refreshed."
|
||||
setDefaultValue(false)
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_USE_GENERIC_TITLE = "pref_use_generic_title"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,97 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
typealias ApiChapterPagesResponse = ApiResponse<ApiChapterData>
|
||||
typealias ApiSearchResponse = ApiResponse<ApiSearchObject>
|
||||
typealias ApiMangaDetailsResponse = ApiResponse<ApiMangaObject>
|
||||
|
||||
// Base classes
|
||||
@Serializable
|
||||
class ApiResponse<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiResponseError(
|
||||
val errors: List<ApiErrorMessages>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiErrorMessages(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PublicIPResponse(
|
||||
val ip: String,
|
||||
)
|
||||
|
||||
// Chapter metadata (pages)
|
||||
@Serializable
|
||||
class ApiChapterData(
|
||||
val chapter: ApiChapter,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiChapter(
|
||||
val pages: String,
|
||||
val mangaID: Int,
|
||||
@SerialName("number") val chapterNumber: Float,
|
||||
val manga: ApiMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiChapterPages(
|
||||
@SerialName("p") val page: String,
|
||||
@SerialName("i") val images: List<String>,
|
||||
)
|
||||
|
||||
// Search, Popular, Latest
|
||||
@Serializable
|
||||
class ApiSearchObject(
|
||||
val search: ApiSearchResults,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiSearchResults(
|
||||
val rows: List<ApiMangaSearchItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaSearchItem(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val image: String,
|
||||
val author: String,
|
||||
val latestChapter: Float,
|
||||
val genres: String,
|
||||
)
|
||||
|
||||
// Manga Details, Chapters
|
||||
@Serializable
|
||||
class ApiMangaObject(
|
||||
val manga: ApiMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaData(
|
||||
val title: String?,
|
||||
val status: String?,
|
||||
val image: String?,
|
||||
val author: String?,
|
||||
val artist: String?,
|
||||
val genres: String?,
|
||||
val description: String?,
|
||||
val alternativeTitle: String?,
|
||||
val slug: String?,
|
||||
val chapters: List<ApiMangaChapterList>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaChapterList(
|
||||
val number: Float,
|
||||
val title: String,
|
||||
val date: String,
|
||||
)
|
@ -1,42 +1,70 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
class GraphQLTag(
|
||||
val refreshUrl: String? = null,
|
||||
)
|
||||
|
||||
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
|
||||
|
||||
val PAGES_QUERY = buildQuery {
|
||||
val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int ->
|
||||
"""
|
||||
query(%mangaSource: MangaSource, %slug: String!, %number: Float!) {
|
||||
chapter(x: %mangaSource, slug: %slug, number: %number) {
|
||||
pages
|
||||
{
|
||||
search(x: $mangaSource, q: "$query", genre: "$genre", mod: $order, offset: ${(page - 1) * 30}) {
|
||||
rows {
|
||||
title,
|
||||
author,
|
||||
slug,
|
||||
image,
|
||||
genres,
|
||||
latestChapter
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorMessages(
|
||||
val message: String,
|
||||
)
|
||||
val mangaDetailsQuery = { mangaSource: String, slug: String ->
|
||||
"""
|
||||
{
|
||||
manga(x: $mangaSource, slug: "$slug") {
|
||||
title,
|
||||
slug,
|
||||
status,
|
||||
image,
|
||||
author,
|
||||
artist,
|
||||
genres,
|
||||
description,
|
||||
alternativeTitle
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPagesResponse(
|
||||
val data: ApiChapterData?,
|
||||
val errors: List<ApiErrorMessages>?,
|
||||
)
|
||||
val mangaChapterListQuery = { mangaSource: String, slug: String ->
|
||||
"""
|
||||
{
|
||||
manga(x: $mangaSource, slug: "$slug") {
|
||||
slug,
|
||||
chapters {
|
||||
number,
|
||||
title,
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterData(
|
||||
val chapter: ApiChapter?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapter(
|
||||
val pages: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPages(
|
||||
val p: String,
|
||||
val i: List<String>,
|
||||
)
|
||||
val pagesQuery = { mangaSource: String, slug: String, number: Float ->
|
||||
"""
|
||||
{
|
||||
chapter(x: $mangaSource, slug: "$slug", number: $number) {
|
||||
pages,
|
||||
mangaID,
|
||||
number,
|
||||
manga {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 3
|
||||
baseVersionCode = 4
|
||||
|
@ -14,6 +14,12 @@ class CookieRedirectInterceptor(private val client: OkHttpClient) : Interceptor
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val contentType = response.header("content-type")
|
||||
if (contentType != null && contentType.startsWith("image/", ignoreCase = true)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// ignore requests that already have completed the JS challenge
|
||||
if (response.headers["vary"] != null) return response
|
||||
|
||||
|
8
src/all/baobua/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'BaoBua'
|
||||
extClass = '.BaoBua'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/baobua/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
src/all/baobua/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/baobua/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
src/all/baobua/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/all/baobua/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,109 @@
|
||||
package eu.kanade.tachiyomi.extension.all.baobua
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BaoBua() : SimpleParsedHttpSource() {
|
||||
|
||||
override val baseUrl = "https://www.baobua.net"
|
||||
override val lang = "all"
|
||||
override val name = "BaoBua"
|
||||
override val supportsLatest = false
|
||||
|
||||
override fun simpleMangaSelector() = "article.post"
|
||||
|
||||
override fun simpleMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a.popunder")!!.absUrl("href"))
|
||||
title = element.selectFirst("div.read-title")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
}
|
||||
|
||||
override fun simpleNextPageSelector(): String = "nav.pagination a.next"
|
||||
|
||||
// region popular
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl?page=$page", headers)
|
||||
// endregion
|
||||
|
||||
// region latest
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
// endregion
|
||||
|
||||
// region Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val filter = filters.firstInstance<SourceCategorySelector>()
|
||||
return filter.selectedCategory?.let {
|
||||
GET(it.buildUrl(baseUrl), headers)
|
||||
} ?: run {
|
||||
baseUrl.toHttpUrl().newBuilder()
|
||||
.addEncodedQueryParameter("q", query)
|
||||
.addEncodedQueryParameter("page", page.toString())
|
||||
.build()
|
||||
.let { GET(it, headers) }
|
||||
}
|
||||
}
|
||||
|
||||
// region Details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val trailItemsEl = document.selectFirst("div.breadcrumb-trail > ul.trail-items")!!
|
||||
return SManga.create().apply {
|
||||
title = trailItemsEl.selectFirst("li.trail-end")!!.text()
|
||||
genre = trailItemsEl.select("li:not(.trail-end):not(.trail-begin)").joinToString { it.text() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "html"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
chapter_number = 0F
|
||||
setUrlWithoutDomain(element.selectFirst("div.breadcrumb-trail li.trail-end > a")!!.absUrl("href"))
|
||||
date_upload = POST_DATE_FORMAT.tryParse(element.selectFirst("span.item-metadata.posts-date")?.text())
|
||||
name = "Gallery"
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val basePageUrl = document.selectFirst("div.breadcrumb-trail li.trail-end > a")!!.absUrl("href")
|
||||
|
||||
val maxPage: Int = document.selectFirst("div.nav-links > a.next.page-numbers")?.text()?.toInt() ?: 1
|
||||
|
||||
var pageIndex = 0
|
||||
return (1..maxPage).flatMap { pageNum ->
|
||||
val doc = if (pageNum == 1) {
|
||||
document
|
||||
} else {
|
||||
client.newCall(GET("$basePageUrl?p=$pageNum", headers)).execute().asJsoup()
|
||||
}
|
||||
|
||||
doc.select("div.entry-content.read-details img.wp-image")
|
||||
.map { Page(pageIndex++, imageUrl = it.absUrl("src")) }
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Filter.Header("NOTE: Unable to further search in the category!"),
|
||||
Filter.Separator(),
|
||||
SourceCategorySelector.create(baseUrl),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
private val POST_DATE_FORMAT = SimpleDateFormat("EEE MMM dd yyyy", Locale.US)
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.extension.all.baobua
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
abstract class SimpleParsedHttpSource : ParsedHttpSource() {
|
||||
|
||||
abstract fun simpleMangaSelector(): String
|
||||
|
||||
abstract fun simpleMangaFromElement(element: Element): SManga
|
||||
|
||||
abstract fun simpleNextPageSelector(): String?
|
||||
|
||||
// region popular
|
||||
override fun popularMangaSelector() = simpleMangaSelector()
|
||||
override fun popularMangaNextPageSelector() = simpleNextPageSelector()
|
||||
override fun popularMangaFromElement(element: Element) = simpleMangaFromElement(element)
|
||||
// endregion
|
||||
|
||||
// region last
|
||||
override fun latestUpdatesSelector() =
|
||||
if (supportsLatest) simpleMangaSelector() else throw throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) =
|
||||
if (supportsLatest) simpleMangaFromElement(element) else throw throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() =
|
||||
if (supportsLatest) simpleNextPageSelector() else throw throw UnsupportedOperationException()
|
||||
// endregion
|
||||
|
||||
// region search
|
||||
override fun searchMangaSelector() = simpleMangaSelector()
|
||||
override fun searchMangaFromElement(element: Element) = simpleMangaFromElement(element)
|
||||
override fun searchMangaNextPageSelector() = simpleNextPageSelector()
|
||||
// endregion
|
||||
|
||||
override fun chapterListSelector() = simpleMangaSelector()
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.extension.all.baobua
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
data class SourceCategory(private val name: String, var cat: String) {
|
||||
override fun toString() = this.name
|
||||
|
||||
fun buildUrl(baseUrl: String): String {
|
||||
return "$baseUrl/".toHttpUrl().newBuilder()
|
||||
.addEncodedQueryParameter("cat", this.cat)
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
|
||||
class SourceCategorySelector(
|
||||
name: String,
|
||||
categories: List<SourceCategory>,
|
||||
) : Filter.Select<SourceCategory>(name, categories.toTypedArray()) {
|
||||
|
||||
val selectedCategory: SourceCategory?
|
||||
get() = if (state > 0) values[state] else null
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(baseUrl: String): SourceCategorySelector {
|
||||
val options = listOf(
|
||||
SourceCategory("unselected", ""),
|
||||
SourceCategory("大胸美女", "YmpydEtkNzV5NHJKcDJYVGtOVW0yZz09"),
|
||||
SourceCategory("巨乳美女", "Q09EdlMvMHgweERrUitScTFTaDM4Zz09"),
|
||||
SourceCategory("全裸写真", "eXZzejJPNFRVNzJqKzFDUmNzZEU2QT09"),
|
||||
SourceCategory("chinese", "bG9LamJsWWdSbGcyY0FEZytldkhTZz09"),
|
||||
SourceCategory("chinese models", "OCtTSEI2YzRTcWMvWUsyeDM0aHdzdUIwWDlHMERZUEZaVHUwUEVUVWo3QT0"),
|
||||
SourceCategory("korean", "Tm1ydGlaZ1A2YWM3a3BvYWh6L3dIdz09"),
|
||||
SourceCategory("korea", "bzRjeWR0akQrRWpxRE1xOGF6TW5Tdz09"),
|
||||
SourceCategory("korean models", "TGZTVGtwOCtxTW1TQU1KYWhUb01DQT09"),
|
||||
SourceCategory("big boobs", "UmFLQVkvVndGNlpPckwvZkpVaEE4UT09"),
|
||||
SourceCategory("adult", "b2RFSnlwdWxyREMxVmRpcThKVXRLUT09"),
|
||||
SourceCategory("nude-art", "djFqa293VmFZMEJLdDlUWndsMGtldz09"),
|
||||
SourceCategory("Asian adult photo", "SHBGZHFueTVNeUlxVHRLaU53RjU2NS9VcjNxRVg3VnhqTGJoK25YaVQ1UT0"),
|
||||
SourceCategory("cosplay", "OEI2c000ZDBxakwydjZIUVJaRnlMQT09"),
|
||||
SourceCategory("hot", "c3VRb3RJZ2wrU2tTYmpGSUVqMnFndz09"),
|
||||
SourceCategory("big breast", "dkQ3b0RiK0xpZDRlMVNSY3lUNkJXQT09"),
|
||||
)
|
||||
|
||||
return SourceCategorySelector("Category", options)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Bato.to'
|
||||
extClass = '.BatoToFactory'
|
||||
extVersionCode = 49
|
||||
extVersionCode = 50
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -279,7 +279,7 @@ open class BatoTo(
|
||||
manga.title = infoElement.select("h3").text().removeEntities()
|
||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||
.attr("abs:src")
|
||||
manga.url = infoElement.select("h3 a").attr("abs:href")
|
||||
manga.setUrlWithoutDomain(infoElement.select("h3 a").attr("abs:href"))
|
||||
return MangasPage(listOf(manga), false)
|
||||
}
|
||||
|
||||
@ -405,7 +405,7 @@ open class BatoTo(
|
||||
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
|
||||
.select("channel > item").map { item ->
|
||||
SChapter.create().apply {
|
||||
url = item.selectFirst("guid")!!.text()
|
||||
setUrlWithoutDomain(item.selectFirst("guid")!!.text())
|
||||
name = item.selectFirst("title")!!.text()
|
||||
date_upload = parseAltChapterDate(item.selectFirst("pubDate")!!.text())
|
||||
}
|
||||
|
@ -1,8 +1,12 @@
|
||||
ext {
|
||||
extName = 'Buon Dua'
|
||||
extClass = '.BuonDua'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 4
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:randomua"))
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
package eu.kanade.tachiyomi.extension.all.buondua
|
||||
|
||||
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
|
||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
@ -8,11 +11,15 @@ 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 keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BuonDua() : ParsedHttpSource() {
|
||||
override val baseUrl = "https://buondua.com"
|
||||
@ -20,6 +27,13 @@ class BuonDua() : ParsedHttpSource() {
|
||||
override val name = "Buon Dua"
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 10, 1, TimeUnit.SECONDS)
|
||||
.setRandomUserAgent(UserAgentType.MOBILE)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
@ -43,10 +57,10 @@ class BuonDua() : ParsedHttpSource() {
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/hot?start=${20 * (page - 1)}")
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
@ -57,6 +71,7 @@ class BuonDua() : ParsedHttpSource() {
|
||||
else -> popularMangaRequest(page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
// Details
|
||||
@ -72,34 +87,27 @@ class BuonDua() : ParsedHttpSource() {
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(element.select(".is-current").first()!!.attr("abs:href"))
|
||||
chapter.chapter_number = 0F
|
||||
chapter.name = element.select(".article-header").text()
|
||||
chapter.date_upload = SimpleDateFormat("H:m DD-MM-yyyy", Locale.US).parse(element.select(".article-info > small").text())?.time ?: 0L
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "html"
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val numpages = document.selectFirst(".pagination-list")!!.select(".pagination-link")
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
numpages.forEachIndexed { index, page ->
|
||||
val doc = when (index) {
|
||||
0 -> document
|
||||
else -> client.newCall(GET(page.attr("abs:href"))).execute().asJsoup()
|
||||
}
|
||||
doc.select(".article-fulltext img").forEach {
|
||||
val itUrl = it.attr("abs:src")
|
||||
pages.add(Page(pages.size, "", itUrl))
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val doc = response.asJsoup()
|
||||
val dateUploadStr = doc.selectFirst(".article-info > small")?.text()
|
||||
val dateUpload = DATE_FORMAT.tryParse(dateUploadStr)
|
||||
val maxPage = doc.select("nav.pagination:first-of-type a.pagination-link").last()?.text()?.toInt() ?: 1
|
||||
val basePageUrl = response.request.url
|
||||
return (maxPage downTo 1).map { page ->
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain("$basePageUrl?page=$page")
|
||||
name = "Page $page"
|
||||
date_upload = dateUpload
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(".article-fulltext img")
|
||||
.mapIndexed { i, imgEl -> Page(i, imageUrl = imgEl.absUrl("src")) }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
@ -114,4 +122,8 @@ class BuonDua() : ParsedHttpSource() {
|
||||
class TagFilter : Filter.Text("Tag ID")
|
||||
|
||||
private inline fun <reified T> Iterable<*>.findInstance() = find { it is T } as? T
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT = SimpleDateFormat("H:m DD-MM-yyyy", Locale.US)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
ignored_groups_title=Ignored Groups
|
||||
ignored_groups_summary=Chapters from these groups won't be shown.\nOne group name per line (case-insensitive)
|
||||
ignored_tags_title=Ignored Tags
|
||||
ignored_tags_summary=Manga with these tags won't show up when browsing.\nOne tag per line (case-insensitive)
|
||||
show_alternative_titles_title=Show Alternative Titles
|
||||
show_alternative_titles_on=Adds alternative titles to the description
|
||||
show_alternative_titles_off=Does not show alternative titles to the description
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Comick'
|
||||
extClass = '.ComickFactory'
|
||||
extVersionCode = 55
|
||||
extVersionCode = 56
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -79,6 +79,12 @@ abstract class Comick(
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = IGNORED_TAGS_PREF
|
||||
title = intl["ignored_tags_title"]
|
||||
summary = intl["ignored_tags_summary"]
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_ALTERNATIVE_TITLES_PREF
|
||||
title = intl["show_alternative_titles_title"]
|
||||
@ -184,6 +190,14 @@ abstract class Comick(
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
|
||||
private val SharedPreferences.ignoredTags: String
|
||||
get() = getString(IGNORED_TAGS_PREF, "")
|
||||
?.split("\n")
|
||||
?.map(String::trim)
|
||||
?.filter(String::isNotEmpty)
|
||||
.orEmpty()
|
||||
.joinToString(",")
|
||||
|
||||
private val SharedPreferences.showAlternativeTitles: Boolean
|
||||
get() = getBoolean(SHOW_ALTERNATIVE_TITLES_PREF, SHOW_ALTERNATIVE_TITLES_DEFAULT)
|
||||
|
||||
@ -243,8 +257,13 @@ abstract class Comick(
|
||||
|
||||
/** Popular Manga **/
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$apiUrl/v1.0/search?sort=follow&limit=$LIMIT&page=$page&tachiyomi=true"
|
||||
return GET(url, headers)
|
||||
return searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("follow"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
@ -257,8 +276,13 @@ abstract class Comick(
|
||||
|
||||
/** Latest Manga **/
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/v1.0/search?sort=uploaded&limit=$LIMIT&page=$page&tachiyomi=true"
|
||||
return GET(url, headers)
|
||||
return searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("uploaded"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
@ -316,7 +340,7 @@ abstract class Comick(
|
||||
}
|
||||
|
||||
private fun addTagQueryParameters(builder: Builder, tags: String, parameterName: String) {
|
||||
tags.split(",").forEach {
|
||||
tags.split(",").filter(String::isNotEmpty).forEach {
|
||||
builder.addQueryParameter(
|
||||
parameterName,
|
||||
it.trim().lowercase().replace(SPACE_AND_SLASH_REGEX, "-")
|
||||
@ -412,6 +436,7 @@ abstract class Comick(
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
addTagQueryParameters(this, preferences.ignoredTags, "excluded-tags")
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
addQueryParameter("limit", "$LIMIT")
|
||||
addQueryParameter("page", "$page")
|
||||
@ -587,6 +612,7 @@ abstract class Comick(
|
||||
const val SLUG_SEARCH_PREFIX = "id:"
|
||||
private val SPACE_AND_SLASH_REGEX = Regex("[ /]")
|
||||
private const val IGNORED_GROUPS_PREF = "IgnoredGroups"
|
||||
private const val IGNORED_TAGS_PREF = "IgnoredTags"
|
||||
private const val SHOW_ALTERNATIVE_TITLES_PREF = "ShowAlternativeTitles"
|
||||
const val SHOW_ALTERNATIVE_TITLES_DEFAULT = false
|
||||
private const val INCLUDE_MU_TAGS_PREF = "IncludeMangaUpdatesTags"
|
||||
|
@ -9,7 +9,7 @@ fun getFilters(): FilterList {
|
||||
GenreFilter("Genre", getGenresList),
|
||||
DemographicFilter("Demographic", getDemographicList),
|
||||
TypeFilter("Type", getTypeList),
|
||||
SortFilter("Sort", getSortsList),
|
||||
SortFilter(),
|
||||
StatusFilter("Status", getStatusList),
|
||||
ContentRatingFilter("Content Rating", getContentRatingList),
|
||||
CompletedFilter("Completely Scanlated?"),
|
||||
@ -50,8 +50,8 @@ internal class FromYearFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class ToYearFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class SortFilter(name: String, sortList: List<Pair<String, String>>, state: Int = 0) :
|
||||
SelectFilter(name, sortList, state)
|
||||
internal class SortFilter(defaultValue: String? = null, state: Int = 0) :
|
||||
SelectFilter("Sort", getSortsList, state, defaultValue)
|
||||
|
||||
internal class StatusFilter(name: String, statusList: List<Pair<String, String>>, state: Int = 0) :
|
||||
SelectFilter(name, statusList, state)
|
||||
@ -66,8 +66,8 @@ internal open class TextFilter(name: String) : Filter.Text(name)
|
||||
|
||||
internal open class CheckBoxFilter(name: String, val value: String = "") : Filter.CheckBox(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) {
|
||||
internal open class SelectFilter(name: String, private val vals: List<Pair<String, String>>, state: Int = 0, defaultValue: String? = null) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: state) {
|
||||
fun getValue() = vals[state].second
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'DeviantArt'
|
||||
extClass = '.DeviantArt'
|
||||
extVersionCode = 7
|
||||
extVersionCode = 8
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -134,12 +134,11 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
nextUrl = newDocument.selectFirst("[rel=next]")?.absUrl("href")
|
||||
}
|
||||
|
||||
return chapterList.toList().also(::indexChapterList)
|
||||
return chapterList.also(::orderChapterList).toList()
|
||||
}
|
||||
|
||||
private fun parseToChapterList(document: Document): List<SChapter> {
|
||||
val items = document.select("item")
|
||||
return items.map {
|
||||
return document.select("item").map {
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(it.selectFirst("link")!!.text())
|
||||
name = it.selectFirst("title")!!.text()
|
||||
@ -149,17 +148,15 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
}
|
||||
}
|
||||
|
||||
private fun indexChapterList(chapterList: List<SChapter>) {
|
||||
// DeviantArt allows users to arrange galleries arbitrarily so we will
|
||||
// primitively index the list by checking the first and last dates
|
||||
if (chapterList.first().date_upload > chapterList.last().date_upload) {
|
||||
chapterList.forEachIndexed { i, chapter ->
|
||||
chapter.chapter_number = chapterList.size - i.toFloat()
|
||||
}
|
||||
} else {
|
||||
chapterList.forEachIndexed { i, chapter ->
|
||||
chapter.chapter_number = i.toFloat() + 1
|
||||
}
|
||||
private fun orderChapterList(chapterList: MutableList<SChapter>) {
|
||||
// In Mihon's updates tab, chapters are ordered by source instead
|
||||
// of chapter number, so to avoid updates being shown in reverse,
|
||||
// disregard source order and order chronologically instead
|
||||
if (chapterList.first().date_upload < chapterList.last().date_upload) {
|
||||
chapterList.reverse()
|
||||
}
|
||||
chapterList.forEachIndexed { i, chapter ->
|
||||
chapter.chapter_number = chapterList.size - i.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ ext {
|
||||
extClass = '.EternalMangasFactory'
|
||||
themePkg = 'mangaesp'
|
||||
baseUrl = 'https://eternalmangas.com'
|
||||
overrideVersionCode = 2
|
||||
overrideVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -2,16 +2,24 @@ package eu.kanade.tachiyomi.extension.all.eternalmangas
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangaesp.MangaEsp
|
||||
import eu.kanade.tachiyomi.multisrc.mangaesp.SeriesDto
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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 kotlinx.serialization.Serializable
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import rx.Observable
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@ -26,16 +34,58 @@ open class EternalMangas(
|
||||
) {
|
||||
override val useApiSearch = true
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val responseData = json.decodeFromString<LatestUpdatesDto>(response.body.string())
|
||||
val mangas = responseData.updates[internalLang]?.flatten()?.map { it.toSManga(seriesPath) } ?: emptyList()
|
||||
return MangasPage(mangas, false)
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return super.fetchSearchManga(page, "", createSortFilter("views", false))
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return super.fetchSearchManga(page, "", createSortFilter("updated_at", false))
|
||||
}
|
||||
|
||||
override fun List<SeriesDto>.additionalParse(): List<SeriesDto> {
|
||||
return this.filter { it.language == internalLang }.toMutableList()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/comics", headers)
|
||||
}
|
||||
|
||||
private val dataUrl = "https://raw.githubusercontent.com/bapeey/extensions-tools/refs/heads/main/keiyoushi/eternalmangas/values.txt"
|
||||
|
||||
override fun searchMangaParse(
|
||||
response: Response,
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): MangasPage {
|
||||
val (apiComicsUrl, jsonHeaders, useApi, scriptSelector, comicsRegex) = client.newCall(GET(dataUrl)).execute().body.string().split("\n")
|
||||
val apiSearch = useApi == "1"
|
||||
comicsList = if (apiSearch) {
|
||||
val headersJson = json.parseToJsonElement(jsonHeaders).jsonObject
|
||||
val apiHeaders = headersBuilder()
|
||||
headersJson.forEach { (key, jsonElement) ->
|
||||
var value = jsonElement.jsonPrimitive.contentOrNull.orEmpty()
|
||||
if (value.startsWith("1-")) {
|
||||
val match = value.substringAfter("-").toRegex().find(response.body.string())
|
||||
value = match?.groupValues?.get(1).orEmpty()
|
||||
} else {
|
||||
value = value.substringAfter("-")
|
||||
}
|
||||
apiHeaders.add(key, value)
|
||||
}
|
||||
val apiResponse = client.newCall(GET(apiComicsUrl, apiHeaders.build())).execute()
|
||||
json.decodeFromString<List<SeriesDto>>(apiResponse.body.string()).toMutableList()
|
||||
} else {
|
||||
val script = response.asJsoup().select(scriptSelector).joinToString { it.data() }
|
||||
val jsonString = comicsRegex.toRegex().find(script)?.groupValues?.get(1)
|
||||
?: throw Exception(intl["comics_list_error"])
|
||||
val unescapedJson = jsonString.unescape()
|
||||
json.decodeFromString<List<SeriesDto>>(unescapedJson).toMutableList()
|
||||
}
|
||||
|
||||
return parseComicsList(page, query, filters)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||
val body = jsRedirect(response)
|
||||
|
||||
@ -92,7 +142,7 @@ open class EternalMangas(
|
||||
private fun jsRedirect(response: Response): String {
|
||||
var body = response.body.string()
|
||||
val document = Jsoup.parse(body)
|
||||
document.selectFirst("body > form[method=post]")?.let {
|
||||
document.selectFirst("body > form[method=post], body > div[hidden] > form[method=post]")?.let {
|
||||
val action = it.attr("action")
|
||||
val inputs = it.select("input")
|
||||
|
||||
@ -106,8 +156,13 @@ open class EternalMangas(
|
||||
return body
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class LatestUpdatesDto(
|
||||
val updates: Map<String, List<List<SeriesDto>>>,
|
||||
)
|
||||
private fun createSortFilter(value: String, ascending: Boolean = false): FilterList {
|
||||
val sortProperties = getSortProperties()
|
||||
val index = sortProperties.indexOfFirst { it.value == value }.takeIf { it >= 0 } ?: 0
|
||||
return FilterList(
|
||||
SortByFilter("", sortProperties).apply {
|
||||
state = Filter.Sort.Selection(index, ascending)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.a3manga.A3MangaUrlActivity"
|
||||
android:name=".all.globalcomix.GlobalComixUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<intent-filter
|
||||
android:autoVerify="false"
|
||||
tools:targetApi="23">
|
||||
|
||||
<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}" />
|
||||
<data android:host="*.${SOURCEHOST}" />
|
||||
<data android:pathPattern="/truyen-tranh/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
<data android:host="globalcomix.com" />
|
||||
<data android:scheme="https" />
|
||||
|
||||
<data android:pathPattern="/c/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
5
src/all/globalcomix/assets/i18n/messages_en.properties
Normal file
@ -0,0 +1,5 @@
|
||||
data_saver=Data saver
|
||||
data_saver_summary=Enables smaller, more compressed images
|
||||
invalid_manga_id=Not a valid comic ID
|
||||
show_locked_chapters=Show chapters with pay-walled pages
|
||||
show_locked_chapters_summary=Display chapters that require an account with a premium subscription
|
12
src/all/globalcomix/build.gradle
Normal file
@ -0,0 +1,12 @@
|
||||
ext {
|
||||
extName = 'GlobalComix'
|
||||
extClass = '.GlobalComixFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:i18n"))
|
||||
}
|
BIN
src/all/globalcomix/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
src/all/globalcomix/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/globalcomix/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
src/all/globalcomix/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.8 KiB |
BIN
src/all/globalcomix/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,234 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChapterDataDto.Companion.createChapter
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChapterDto
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.ChaptersDto
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.EntityDto
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangaDataDto.Companion.createManga
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangaDto
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.MangasDto
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.dto.UnknownEntity
|
||||
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
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
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.plus
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
abstract class GlobalComix(final override val lang: String, private val extLang: String = lang) :
|
||||
ConfigurableSource, HttpSource() {
|
||||
|
||||
override val name = "GlobalComix"
|
||||
override val baseUrl = webUrl
|
||||
override val supportsLatest = true
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
serializersModule += SerializersModule {
|
||||
polymorphic(EntityDto::class) {
|
||||
defaultDeserializer { UnknownEntity.serializer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val intl = Intl(
|
||||
language = lang,
|
||||
baseLanguage = english,
|
||||
availableLanguages = setOf(english),
|
||||
classLoader = this::class.java.classLoader!!,
|
||||
createMessageFileName = { lang -> Intl.createDefaultMessageFileName(lang) },
|
||||
)
|
||||
|
||||
final override fun headersBuilder() = super.headersBuilder().apply {
|
||||
set("Referer", "$baseUrl/")
|
||||
set("Origin", baseUrl)
|
||||
set("x-gc-client", clientId)
|
||||
set("x-gc-identmode", "cookie")
|
||||
}
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.rateLimit(3)
|
||||
.build()
|
||||
|
||||
private fun simpleQueryRequest(page: Int, orderBy: String?, query: String?): Request {
|
||||
val url = apiSearchUrl.toHttpUrl().newBuilder()
|
||||
.addQueryParameter("lang_id[]", extLang)
|
||||
.addQueryParameter("p", page.toString())
|
||||
|
||||
orderBy?.let { url.addQueryParameter("sort", it) }
|
||||
query?.let { url.addQueryParameter("q", it) }
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
simpleQueryRequest(page, orderBy = null, query = null)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage =
|
||||
mangaListParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
simpleQueryRequest(page, "recent", query = null)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||
mangaListParse(response)
|
||||
|
||||
private fun mangaListParse(response: Response): MangasPage {
|
||||
val isSingleItemLookup = response.request.url.toString().startsWith(apiMangaUrl)
|
||||
return if (!isSingleItemLookup) {
|
||||
// Normally, the response is a paginated list of mangas
|
||||
// The results property will be a JSON array
|
||||
response.parseAs<MangasDto>().payload!!.let { dto ->
|
||||
MangasPage(
|
||||
dto.results.map { it -> it.createManga() },
|
||||
dto.pagination.hasNextPage,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// However, when using the 'id:' query prefix (via the UrlActivity for example),
|
||||
// the response is a single manga and the results property will be a JSON object
|
||||
MangasPage(
|
||||
listOf(
|
||||
response.parseAs<MangaDto>().payload!!
|
||||
.results
|
||||
.createManga(),
|
||||
),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// If the query is a slug ID, return the manga directly
|
||||
if (query.startsWith(prefixIdSearch)) {
|
||||
val mangaSlugId = query.removePrefix(prefixIdSearch)
|
||||
|
||||
if (mangaSlugId.isEmpty()) {
|
||||
throw Exception(intl["invalid_manga_id"])
|
||||
}
|
||||
|
||||
val url = apiMangaUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment(mangaSlugId)
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
return simpleQueryRequest(page, orderBy = "relevance", query)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String = "$webComicUrl/${titleToSlug(manga.title)}"
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val url = apiMangaUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment(titleToSlug(manga.title))
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga =
|
||||
response.parseAs<MangaDto>().payload!!
|
||||
.results
|
||||
.createManga()
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val url = apiSearchUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment(manga.url) // manga.url contains the the comic id
|
||||
.addPathSegment("releases")
|
||||
.addQueryParameter("lang_id", extLang)
|
||||
.addQueryParameter("all", "true")
|
||||
.toString()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> =
|
||||
response.parseAs<ChaptersDto>().payload!!.results.filterNot { dto ->
|
||||
dto.isPremium && !preferences.showLockedChapters
|
||||
}.map { it.createChapter() }
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String =
|
||||
"$baseUrl/read/${chapter.url}"
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val chapterKey = chapter.url
|
||||
val url = "$apiChapterUrl/$chapterKey"
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chapterKey = response.request.url.pathSegments.last()
|
||||
val chapterWebUrl = "$webChapterUrl/$chapterKey"
|
||||
|
||||
return response.parseAs<ChapterDto>()
|
||||
.payload!!
|
||||
.results
|
||||
.page_objects!!
|
||||
.map { dto -> if (preferences.useDataSaver) dto.mobile_image_url else dto.desktop_image_url }
|
||||
.mapIndexed { index, url -> Page(index, "$chapterWebUrl/$index", url) }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val dataSaverPref = SwitchPreferenceCompat(screen.context).apply {
|
||||
key = getDataSaverPreferenceKey(extLang)
|
||||
title = intl["data_saver"]
|
||||
summary = intl["data_saver_summary"]
|
||||
setDefaultValue(false)
|
||||
}
|
||||
|
||||
val showLockedChaptersPref = SwitchPreferenceCompat(screen.context).apply {
|
||||
key = getShowLockedChaptersPreferenceKey(extLang)
|
||||
title = intl["show_locked_chapters"]
|
||||
summary = intl["show_locked_chapters_summary"]
|
||||
setDefaultValue(true)
|
||||
}
|
||||
|
||||
screen.addPreference(dataSaverPref)
|
||||
screen.addPreference(showLockedChaptersPref)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T = parseAs(json)
|
||||
|
||||
private val SharedPreferences.useDataSaver
|
||||
get() = getBoolean(getDataSaverPreferenceKey(extLang), false)
|
||||
|
||||
private val SharedPreferences.showLockedChapters
|
||||
get() = getBoolean(getShowLockedChaptersPreferenceKey(extLang), true)
|
||||
|
||||
companion object {
|
||||
fun titleToSlug(title: String) = title.trim()
|
||||
.lowercase(Locale.US)
|
||||
.replace(titleSpecialCharactersRegex, "-")
|
||||
|
||||
val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex()
|
||||
val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix
|
||||
|
||||
const val lockSymbol = "🔒"
|
||||
|
||||
// Language codes used for translations
|
||||
const val english = "en"
|
||||
|
||||
// JSON discriminators
|
||||
const val release = "Release"
|
||||
const val comic = "Comic"
|
||||
const val artist = "Artist"
|
||||
const val releasePage = "ReleasePage"
|
||||
|
||||
// Web requests
|
||||
const val webUrl = "https://globalcomix.com"
|
||||
const val webComicUrl = "$webUrl/c"
|
||||
const val webChapterUrl = "$webUrl/read"
|
||||
const val apiUrl = "https://api.globalcomix.com/v1"
|
||||
const val apiMangaUrl = "$apiUrl/read"
|
||||
const val apiChapterUrl = "$apiUrl/readV2"
|
||||
const val apiSearchUrl = "$apiUrl/comics"
|
||||
|
||||
const val clientId = "gck_d0f170d5729446dcb3b55e6b3ebc7bf6"
|
||||
|
||||
// Search prefix for title ids
|
||||
const val prefixIdSearch = "id:"
|
||||
|
||||
// Preferences
|
||||
fun getDataSaverPreferenceKey(extLang: String): String = "dataSaver_$extLang"
|
||||
fun getShowLockedChaptersPreferenceKey(extLang: String): String = "showLockedChapters_$extLang"
|
@ -0,0 +1,92 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class GlobalComixFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
GlobalComixAlbanian(),
|
||||
GlobalComixArabic(),
|
||||
GlobalComixBulgarian(),
|
||||
GlobalComixBengali(),
|
||||
GlobalComixBrazilianPortuguese(),
|
||||
GlobalComixChineseMandarin(),
|
||||
GlobalComixCzech(),
|
||||
GlobalComixGerman(),
|
||||
GlobalComixDanish(),
|
||||
GlobalComixGreek(),
|
||||
GlobalComixEnglish(),
|
||||
GlobalComixSpanish(),
|
||||
GlobalComixPersian(),
|
||||
GlobalComixFinnish(),
|
||||
GlobalComixFilipino(),
|
||||
GlobalComixFrench(),
|
||||
GlobalComixHindi(),
|
||||
GlobalComixHungarian(),
|
||||
GlobalComixIndonesian(),
|
||||
GlobalComixItalian(),
|
||||
GlobalComixHebrew(),
|
||||
GlobalComixJapanese(),
|
||||
GlobalComixKorean(),
|
||||
GlobalComixLatvian(),
|
||||
GlobalComixMalay(),
|
||||
GlobalComixDutch(),
|
||||
GlobalComixNorwegian(),
|
||||
GlobalComixPolish(),
|
||||
GlobalComixPortugese(),
|
||||
GlobalComixRomanian(),
|
||||
GlobalComixRussian(),
|
||||
GlobalComixSwedish(),
|
||||
GlobalComixSlovak(),
|
||||
GlobalComixSlovenian(),
|
||||
GlobalComixTamil(),
|
||||
GlobalComixThai(),
|
||||
GlobalComixTurkish(),
|
||||
GlobalComixUkrainian(),
|
||||
GlobalComixUrdu(),
|
||||
GlobalComixVietnamese(),
|
||||
GlobalComixChineseCantonese(),
|
||||
)
|
||||
}
|
||||
|
||||
class GlobalComixAlbanian : GlobalComix("al")
|
||||
class GlobalComixArabic : GlobalComix("ar")
|
||||
class GlobalComixBulgarian : GlobalComix("bg")
|
||||
class GlobalComixBengali : GlobalComix("bn")
|
||||
class GlobalComixBrazilianPortuguese : GlobalComix("pt-BR", "br")
|
||||
class GlobalComixChineseMandarin : GlobalComix("zh-Hans", "cn")
|
||||
class GlobalComixCzech : GlobalComix("cs", "cz")
|
||||
class GlobalComixGerman : GlobalComix("de")
|
||||
class GlobalComixDanish : GlobalComix("dk")
|
||||
class GlobalComixGreek : GlobalComix("el")
|
||||
class GlobalComixEnglish : GlobalComix("en")
|
||||
class GlobalComixSpanish : GlobalComix("es")
|
||||
class GlobalComixPersian : GlobalComix("fa")
|
||||
class GlobalComixFinnish : GlobalComix("fi")
|
||||
class GlobalComixFilipino : GlobalComix("fil", "fo")
|
||||
class GlobalComixFrench : GlobalComix("fr")
|
||||
class GlobalComixHindi : GlobalComix("hi")
|
||||
class GlobalComixHungarian : GlobalComix("hu")
|
||||
class GlobalComixIndonesian : GlobalComix("id")
|
||||
class GlobalComixItalian : GlobalComix("it")
|
||||
class GlobalComixHebrew : GlobalComix("he", "iw")
|
||||
class GlobalComixJapanese : GlobalComix("ja", "jp")
|
||||
class GlobalComixKorean : GlobalComix("ko", "kr")
|
||||
class GlobalComixLatvian : GlobalComix("lv")
|
||||
class GlobalComixMalay : GlobalComix("ms", "my")
|
||||
class GlobalComixDutch : GlobalComix("nl")
|
||||
class GlobalComixNorwegian : GlobalComix("no")
|
||||
class GlobalComixPolish : GlobalComix("pl")
|
||||
class GlobalComixPortugese : GlobalComix("pt")
|
||||
class GlobalComixRomanian : GlobalComix("ro")
|
||||
class GlobalComixRussian : GlobalComix("ru")
|
||||
class GlobalComixSwedish : GlobalComix("sv", "se")
|
||||
class GlobalComixSlovak : GlobalComix("sk")
|
||||
class GlobalComixSlovenian : GlobalComix("sl")
|
||||
class GlobalComixTamil : GlobalComix("ta")
|
||||
class GlobalComixThai : GlobalComix("th")
|
||||
class GlobalComixTurkish : GlobalComix("tr")
|
||||
class GlobalComixUkrainian : GlobalComix("uk", "ua")
|
||||
class GlobalComixUrdu : GlobalComix("ur")
|
||||
class GlobalComixVietnamese : GlobalComix("vi")
|
||||
class GlobalComixChineseCantonese : GlobalComix("zh-Hant", "zh")
|
@ -0,0 +1,45 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://globalcomix.com/c/xxx intents and redirects them to
|
||||
* the main tachiyomi process. The idea is to not install the intent filter unless
|
||||
* you have this extension installed, but still let the main tachiyomi app control
|
||||
* things.
|
||||
*/
|
||||
class GlobalComixUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
// Supported path: /c/title-slug
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val titleId = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", prefixIdSearch + titleId)
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("GlobalComixUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("GlobalComixUrlActivity", "Received data URL is unsupported: ${intent?.data}")
|
||||
Toast.makeText(this, "This URL cannot be handled by the GlobalComix extension.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.artist
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
@SerialName(artist)
|
||||
class ArtistDto(
|
||||
val name: String, // Slug
|
||||
val roman_name: String?,
|
||||
) : EntityDto()
|
@ -0,0 +1,63 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.GlobalComix.Companion.dateFormatter
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.lockSymbol
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.release
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
typealias ChapterDto = ResponseDto<ChapterDataDto>
|
||||
typealias ChaptersDto = PaginatedResponseDto<ChapterDataDto>
|
||||
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
@SerialName(release)
|
||||
class ChapterDataDto(
|
||||
val title: String,
|
||||
val chapter: String, // Stringified number
|
||||
val key: String, // UUID, required for /readV2 endpoint
|
||||
val premium_only: Int? = 0,
|
||||
val published_time: String,
|
||||
|
||||
// Only available when calling the /readV2 endpoint
|
||||
val page_objects: List<PageDataDto>?,
|
||||
) : EntityDto() {
|
||||
val isPremium: Boolean
|
||||
get() = premium_only == 1
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create an [SChapter] instance from the JSON DTO element.
|
||||
*/
|
||||
fun ChapterDataDto.createChapter(): SChapter {
|
||||
val chapterName = mutableListOf<String>()
|
||||
if (isPremium) {
|
||||
chapterName.add(lockSymbol)
|
||||
}
|
||||
|
||||
chapter.let {
|
||||
if (it.isNotEmpty()) {
|
||||
chapterName.add("Ch.$it")
|
||||
}
|
||||
}
|
||||
|
||||
title.let {
|
||||
if (it.isNotEmpty()) {
|
||||
if (chapterName.isNotEmpty()) {
|
||||
chapterName.add("-")
|
||||
}
|
||||
chapterName.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
return SChapter.create().apply {
|
||||
url = key
|
||||
name = chapterName.joinToString(" ")
|
||||
chapter_number = chapter.toFloatOrNull() ?: 0f
|
||||
date_upload = dateFormatter.tryParse(published_time)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class EntityDto {
|
||||
val id: Long = -1
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class UnknownEntity() : EntityDto()
|
@ -0,0 +1,49 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.comic
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
typealias MangaDto = ResponseDto<MangaDataDto>
|
||||
typealias MangasDto = PaginatedResponseDto<MangaDataDto>
|
||||
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
@SerialName(comic)
|
||||
class MangaDataDto(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val status_name: String?,
|
||||
val category_name: String?,
|
||||
val image_url: String?,
|
||||
val artist: ArtistDto,
|
||||
) : EntityDto() {
|
||||
companion object {
|
||||
/**
|
||||
* Create an [SManga] instance from the JSON DTO element.
|
||||
*/
|
||||
fun MangaDataDto.createManga(): SManga =
|
||||
SManga.create().also {
|
||||
it.initialized = true
|
||||
it.url = id.toString()
|
||||
it.description = description
|
||||
it.author = artist.let { it.roman_name ?: it.name }
|
||||
it.status = status_name?.let(::convertStatus) ?: SManga.UNKNOWN
|
||||
it.genre = category_name
|
||||
it.title = name
|
||||
it.thumbnail_url = image_url
|
||||
}
|
||||
|
||||
private fun convertStatus(status: String): Int {
|
||||
return when (status) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Preview" -> SManga.ONGOING
|
||||
"Finished" -> SManga.COMPLETED
|
||||
"On hold" -> SManga.ON_HIATUS
|
||||
"Cancelled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.globalcomix.releasePage
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
@SerialName(releasePage)
|
||||
class PageDataDto(
|
||||
val is_page_paid: Boolean,
|
||||
val desktop_image_url: String,
|
||||
val mobile_image_url: String,
|
||||
) : EntityDto()
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.extension.all.globalcomix.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class PaginatedResponseDto<T : EntityDto>(
|
||||
val payload: PaginatedPayloadDto<T>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PaginatedPayloadDto<T : EntityDto>(
|
||||
val results: List<T> = emptyList(),
|
||||
val pagination: PaginationStateDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ResponseDto<T : EntityDto>(
|
||||
val payload: PayloadDto<T>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PayloadDto<T : EntityDto>(
|
||||
val results: T,
|
||||
)
|
||||
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
class PaginationStateDto(
|
||||
val page: Int = 1,
|
||||
val per_page: Int = 0,
|
||||
val total_pages: Int = 0,
|
||||
val total_results: Int = 0,
|
||||
) {
|
||||
val hasNextPage: Boolean
|
||||
get() = page < total_pages
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Hitomi'
|
||||
extClass = '.HitomiFactory'
|
||||
extVersionCode = 38
|
||||
extVersionCode = 39
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@ -52,7 +52,7 @@ class Hitomi(
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::updateImageUrlInterceptor)
|
||||
.addInterceptor(::imageUrlInterceptor)
|
||||
.apply {
|
||||
interceptors().add(0, ::streamResetRetry)
|
||||
}
|
||||
@ -491,18 +491,20 @@ class Hitomi(
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
private suspend fun Gallery.toSManga() = SManga.create().apply {
|
||||
private fun Gallery.toSManga() = SManga.create().apply {
|
||||
title = this@toSManga.title
|
||||
url = galleryurl
|
||||
author = groups?.joinToString { it.formatted } ?: artists?.joinToString { it.formatted }
|
||||
artist = artists?.joinToString { it.formatted }
|
||||
genre = tags?.joinToString { it.formatted }
|
||||
thumbnail_url = files.first().let {
|
||||
val hash = it.hash
|
||||
val imageId = imageIdFromHash(hash)
|
||||
val subDomain = 'a' + subdomainOffset(imageId)
|
||||
|
||||
"https://${subDomain}tn.$cdnDomain/avifbigtn/${thumbPathFromHash(hash)}/$hash.avif"
|
||||
HttpUrl.Builder().apply {
|
||||
scheme("https")
|
||||
host(IMAGE_LOOPBACK_HOST)
|
||||
addQueryParameter(IMAGE_THUMBNAIL, "true")
|
||||
addQueryParameter(IMAGE_GIF, it.isGif.toString())
|
||||
fragment(it.hash)
|
||||
}.toString()
|
||||
}
|
||||
description = buildString {
|
||||
japaneseTitle?.let {
|
||||
@ -571,11 +573,13 @@ class Hitomi(
|
||||
.substringBefore(".")
|
||||
|
||||
return gallery.files.mapIndexed { idx, img ->
|
||||
// actual logic in updateImageUrlInterceptor
|
||||
val imageUrl = "http://127.0.0.1".toHttpUrl().newBuilder()
|
||||
.fragment(img.hash)
|
||||
.build()
|
||||
.toString()
|
||||
// actual logic in imageUrlInterceptor
|
||||
val imageUrl = HttpUrl.Builder().apply {
|
||||
scheme("https")
|
||||
host(IMAGE_LOOPBACK_HOST)
|
||||
addQueryParameter(IMAGE_GIF, img.isGif.toString())
|
||||
fragment(img.hash)
|
||||
}.toString()
|
||||
|
||||
Page(
|
||||
idx,
|
||||
@ -677,18 +681,38 @@ class Hitomi(
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateImageUrlInterceptor(chain: Interceptor.Chain): Response {
|
||||
private fun imageUrlInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.url.host != "127.0.0.1") {
|
||||
if (request.url.host != IMAGE_LOOPBACK_HOST) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val hash = request.url.fragment!!
|
||||
val commonId = runBlocking { commonImageId() }
|
||||
val imageId = imageIdFromHash(hash)
|
||||
val subDomain = runBlocking { (subdomainOffset(imageId) + 1) }
|
||||
val isThumbnail = request.url.queryParameter(IMAGE_THUMBNAIL) == "true"
|
||||
val isGif = request.url.queryParameter(IMAGE_GIF) == "true"
|
||||
|
||||
val imageUrl = "https://a$subDomain.$cdnDomain/$commonId$imageId/$hash.avif"
|
||||
val type = if (isGif) {
|
||||
"webp"
|
||||
} else {
|
||||
"avif"
|
||||
}
|
||||
val imageId = imageIdFromHash(hash)
|
||||
val subDomainOffset = runBlocking { subdomainOffset(imageId) }
|
||||
|
||||
val imageUrl = if (isThumbnail) {
|
||||
val subDomain = "${'a' + subDomainOffset}tn"
|
||||
|
||||
"https://$subDomain.$cdnDomain/${type}bigtn/${thumbPathFromHash(hash)}/$hash.$type"
|
||||
} else {
|
||||
val commonId = runBlocking { commonImageId() }
|
||||
val subDomain = if (isGif) {
|
||||
"w${subDomainOffset + 1}"
|
||||
} else {
|
||||
"a${subDomainOffset + 1}"
|
||||
}
|
||||
|
||||
"https://$subDomain.$cdnDomain/$commonId$imageId/$hash.$type"
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.url(imageUrl)
|
||||
@ -705,3 +729,7 @@ class Hitomi(
|
||||
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
const val IMAGE_LOOPBACK_HOST = "127.0.0.1"
|
||||
const val IMAGE_THUMBNAIL = "is_thumbnail"
|
||||
const val IMAGE_GIF = "is_gif"
|
||||
|
@ -22,7 +22,10 @@ class Gallery(
|
||||
@Serializable
|
||||
class ImageFile(
|
||||
val hash: String,
|
||||
)
|
||||
private val name: String,
|
||||
) {
|
||||
val isGif get() = name.endsWith(".gif")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Tag(
|
||||
|
@ -3,7 +3,7 @@ ext {
|
||||
extClass = '.KdtScans'
|
||||
themePkg = 'madara'
|
||||
baseUrl = 'https://kdtscans.com'
|
||||
overrideVersionCode = 0
|
||||
overrideVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -4,14 +4,11 @@ import eu.kanade.tachiyomi.multisrc.madara.Madara
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class KdtScans : Madara(
|
||||
"KDT Scans",
|
||||
"https://kdtscans.com",
|
||||
"all",
|
||||
dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale("es")),
|
||||
) {
|
||||
override val useNewChapterEndpoint = true
|
||||
override val fetchGenres = false
|
||||
@ -22,6 +19,8 @@ class KdtScans : Madara(
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.c-tabs-item__content:not(:contains([LN]))"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
return super.searchMangaFromElement(element).apply {
|
||||
title = title.cleanupTitle()
|
||||
@ -37,5 +36,7 @@ class KdtScans : Madara(
|
||||
private fun String.cleanupTitle() = replace(titleCleanupRegex, "").trim()
|
||||
|
||||
private val titleCleanupRegex =
|
||||
Regex("""^\[(ESPAÑOL|English)\]\s+(–\s+)?""", RegexOption.IGNORE_CASE)
|
||||
Regex("""^\[(ESPAÑOL|English|HD|VIP)\]\s+(–\s+)?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
override fun chapterListSelector() = "li.wp-manga-chapter:not(:has(.required-login))"
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Kiutaku'
|
||||
extClass = '.Kiutaku'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 3
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -78,7 +78,12 @@ class Kiutaku : ParsedHttpSource() {
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/?search=$query&start=${getPage(page)}", headers)
|
||||
val url = baseUrl.toHttpUrl().newBuilder()
|
||||
.addQueryParameter("search", query)
|
||||
.addQueryParameter("start", getPage(page).toString())
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'SchaleNetwork'
|
||||
extClass = '.KoharuFactory'
|
||||
extVersionCode = 13
|
||||
extVersionCode = 14
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,8 @@ import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.authorization
|
||||
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.token
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
@ -120,7 +122,9 @@ class TurnstileInterceptor(
|
||||
try {
|
||||
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||
val authHeaders = authHeaders(authHeader)
|
||||
val response = noRedirectClient.newCall(POST(authUrl, authHeaders)).execute()
|
||||
val response = runBlocking(Dispatchers.IO) {
|
||||
noRedirectClient.newCall(POST(authUrl, authHeaders)).execute()
|
||||
}
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
with(response) {
|
||||
@ -176,7 +180,9 @@ class TurnstileInterceptor(
|
||||
try {
|
||||
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||
val authHeaders = authHeaders("Bearer $token")
|
||||
val response = noRedirectClient.newCall(GET(authUrl, authHeaders)).execute()
|
||||
val response = runBlocking(Dispatchers.IO) {
|
||||
noRedirectClient.newCall(GET(authUrl, authHeaders)).execute()
|
||||
}
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
return true
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Komga'
|
||||
extClass = '.KomgaFactory'
|
||||
extVersionCode = 61
|
||||
extVersionCode = 63
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
@ -134,7 +134,12 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
||||
else -> "series"
|
||||
}
|
||||
|
||||
val url = "$baseUrl/api/v1/$type?search=$query&page=${page - 1}&deleted=false".toHttpUrl().newBuilder()
|
||||
val url = "$baseUrl/api/v1".toHttpUrl().newBuilder()
|
||||
.addPathSegments(type)
|
||||
.addQueryParameter("search", query)
|
||||
.addQueryParameter("page", (page - 1).toString())
|
||||
.addQueryParameter("deleted", "false")
|
||||
|
||||
val filterList = filters.ifEmpty { getFilterList() }
|
||||
val defaultLibraries = defaultLibraries
|
||||
|
||||
@ -183,7 +188,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "")
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(manga.url)
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(manga.url, headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return if (response.isFromReadList()) {
|
||||
@ -254,7 +259,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
||||
.sortedByDescending { it.chapter_number }
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) = GET("${chapter.url}/pages")
|
||||
override fun pageListRequest(chapter: SChapter) = GET("${chapter.url}/pages", headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val pages = response.parseAs<List<PageDto>>()
|
||||
@ -467,17 +472,17 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).await().parseAs()
|
||||
libraries = client.newCall(GET("$baseUrl/api/v1/libraries", headers)).await().parseAs()
|
||||
collections = client
|
||||
.newCall(GET("$baseUrl/api/v1/collections?unpaged=true"))
|
||||
.newCall(GET("$baseUrl/api/v1/collections?unpaged=true", headers))
|
||||
.await()
|
||||
.parseAs<PageWrapperDto<CollectionDto>>()
|
||||
.content
|
||||
genres = client.newCall(GET("$baseUrl/api/v1/genres")).await().parseAs()
|
||||
tags = client.newCall(GET("$baseUrl/api/v1/tags")).await().parseAs()
|
||||
publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).await().parseAs()
|
||||
genres = client.newCall(GET("$baseUrl/api/v1/genres", headers)).await().parseAs()
|
||||
tags = client.newCall(GET("$baseUrl/api/v1/tags", headers)).await().parseAs()
|
||||
publishers = client.newCall(GET("$baseUrl/api/v1/publishers", headers)).await().parseAs()
|
||||
authors = client
|
||||
.newCall(GET("$baseUrl/api/v1/authors"))
|
||||
.newCall(GET("$baseUrl/api/v1/authors", headers))
|
||||
.await()
|
||||
.parseAs<List<AuthorDto>>()
|
||||
.groupBy { it.role }
|
||||
|
@ -38,7 +38,7 @@ class SeriesDto(
|
||||
metadata.status == "HIATUS" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ")
|
||||
genre = (metadata.genres + metadata.tags + booksMetadata.tags).sorted().distinct().joinToString(", ")
|
||||
description = metadata.summary.ifBlank { booksMetadata.summary }
|
||||
booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map ->
|
||||
author = map["writer"]?.distinct()?.joinToString()
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'MangaFire'
|
||||
extClass = '.MangaFireFactory'
|
||||
extVersionCode = 11
|
||||
extVersionCode = 12
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,9 @@ class MangaFire(
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder().addInterceptor(ImageInterceptor).build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
|
@ -13,7 +13,7 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https"
|
||||
android:host="mangahosted.org"
|
||||
android:host="mangago.fit"
|
||||
android:pathPattern="/.*/..*" />
|
||||
|
||||
</intent-filter>
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Manga Hosted'
|
||||
extClass = '.MangaHostedFactory'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 3
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
||||
|
||||
override val name: String = "Manga Hosted${langOption.nameSuffix}"
|
||||
|
||||
override val baseUrl: String = "https://mangahosted.org"
|
||||
override val baseUrl: String = "https://mangago.fit/${langOption.infix}"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
@ -80,7 +80,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(SEARCH_PREFIX)) {
|
||||
val url = "$baseUrl/${langOption.infix}/${query.substringAfter(SEARCH_PREFIX)}"
|
||||
val url = "$baseUrl/${query.substringAfter(SEARCH_PREFIX)}"
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess().map { response ->
|
||||
val mangas = try { listOf(mangaDetailsParse(response)) } catch (_: Exception) { emptyList() }
|
||||
@ -184,7 +184,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
||||
title = dto.title
|
||||
thumbnail_url = dto.thumbnailUrl
|
||||
status = dto.status
|
||||
url = "/${langOption.infix}/${dto.slug}"
|
||||
url = "/${dto.slug}"
|
||||
genre = dto.genres
|
||||
initialized = true
|
||||
}
|
||||
@ -195,7 +195,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
||||
|
||||
companion object {
|
||||
const val SEARCH_PREFIX = "slug:"
|
||||
val baseApiUrl = "https://api.novelfull.us"
|
||||
val baseApiUrl = "https://api.mangago.fit"
|
||||
val apiUrl = "$baseApiUrl/api"
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ val languages = listOf(
|
||||
LanguageOption("id", "manga-indo", "id"),
|
||||
LanguageOption("it", "manga-italia", "manga-it"),
|
||||
LanguageOption("ja", "mangaraw", "raw"),
|
||||
LanguageOption("pt-BR", "manga-br", orderBy = "ASC"),
|
||||
LanguageOption("pt-BR", "manga-br"),
|
||||
LanguageOption("ru", "manga-ru", "mangaru"),
|
||||
LanguageOption("ru", "manga-ru-hentai", "hentai", " +18"),
|
||||
LanguageOption("ru", "manga-ru-yaoi", "yaoi", " +18 Yaoi"),
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'MangaPark'
|
||||
extClass = '.MangaParkFactory'
|
||||
extVersionCode = 21
|
||||
extVersionCode = 22
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -17,20 +17,19 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.firstInstanceOrNull
|
||||
import keiyoushi.utils.getPreferences
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
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
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@ -54,13 +53,15 @@ class MangaPark(
|
||||
|
||||
private val apiUrl = "$baseUrl/apo/"
|
||||
|
||||
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()
|
||||
override val client = network.cloudflareClient.newBuilder().apply {
|
||||
if (preference.getBoolean(ENABLE_NSFW, true)) {
|
||||
addInterceptor(::siteSettingsInterceptor)
|
||||
addNetworkInterceptor(CookieInterceptor(domain, "nsfw" to "2"))
|
||||
}
|
||||
rateLimitHost(apiUrl.toHttpUrl(), 1)
|
||||
// intentionally after rate limit interceptor so thumbnails are not rate limited
|
||||
addInterceptor(::thumbnailDomainInterceptor)
|
||||
}.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
@ -96,8 +97,10 @@ class MangaPark(
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<SearchResponse>()
|
||||
val pageAsCover = preference.getString(UNCENSORED_COVER_PREF, "off")!!
|
||||
val shortenTitle = preference.getBoolean(SHORTEN_TITLE_PREF, false)
|
||||
|
||||
val entries = result.data.searchComics.items.map { it.data.toSManga() }
|
||||
val entries = result.data.searchComics.items.map { it.data.toSManga(shortenTitle, pageAsCover) }
|
||||
val hasNextPage = entries.size == size
|
||||
|
||||
return MangasPage(entries, hasNextPage)
|
||||
@ -164,8 +167,10 @@ class MangaPark(
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val result = response.parseAs<DetailsResponse>()
|
||||
val pageAsCover = preference.getString(UNCENSORED_COVER_PREF, "off")!!
|
||||
val shortenTitle = preference.getBoolean(SHORTEN_TITLE_PREF, false)
|
||||
|
||||
return result.data.comic.data.toSManga()
|
||||
return result.data.comic.data.toSManga(shortenTitle, pageAsCover)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.substringBeforeLast("#")
|
||||
@ -220,7 +225,7 @@ class MangaPark(
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, "Restart Tachiyomi to apply changes", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(screen.context, "Restart the app to apply changes", Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
@ -231,16 +236,34 @@ class MangaPark(
|
||||
summary = "Refresh chapter list to apply changes"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = ENABLE_NSFW
|
||||
title = "Enable NSFW content"
|
||||
summary = "Clear Cookies & Restart the app to apply changes."
|
||||
setDefaultValue(true)
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHORTEN_TITLE_PREF
|
||||
title = "Remove extra information from title"
|
||||
summary = "Clear database to apply changes\n\n" +
|
||||
"Note: doesn't not work for entries in library"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = UNCENSORED_COVER_PREF
|
||||
title = "Attempt to use Uncensored Cover for Hentai"
|
||||
summary = "Uses first or last chapter page as cover"
|
||||
entries = arrayOf("Off", "First Chapter", "Last Chapter")
|
||||
entryValues = arrayOf("off", "first", "last")
|
||||
setDefaultValue("off")
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T =
|
||||
use { body.string() }.let(json::decodeFromString)
|
||||
|
||||
private inline fun <reified T> List<*>.firstInstanceOrNull(): T? =
|
||||
filterIsInstance<T>().firstOrNull()
|
||||
|
||||
private inline fun <reified T : Any> T.toJsonRequestBody() =
|
||||
json.encodeToString(this).toRequestBody(JSON_MEDIA_TYPE)
|
||||
toJsonString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
private val cookiesNotSet = AtomicBoolean(true)
|
||||
private val latch = CountDownLatch(1)
|
||||
@ -271,6 +294,25 @@ class MangaPark(
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun thumbnailDomainInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val url = request.url
|
||||
|
||||
return if (url.host == THUMBNAIL_LOOPBACK_HOST) {
|
||||
val newUrl = url.newBuilder()
|
||||
.host(domain)
|
||||
.build()
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
@ -298,6 +340,11 @@ class MangaPark(
|
||||
"mpark.to",
|
||||
)
|
||||
|
||||
private const val ENABLE_NSFW = "pref_nsfw"
|
||||
private const val DUPLICATE_CHAPTER_PREF_KEY = "pref_dup_chapters"
|
||||
private const val SHORTEN_TITLE_PREF = "pref_shorten_title"
|
||||
private const val UNCENSORED_COVER_PREF = "pref_uncensored_cover"
|
||||
}
|
||||
}
|
||||
|
||||
const val THUMBNAIL_LOOPBACK_HOST = "127.0.0.1"
|
||||
|
@ -38,33 +38,68 @@ class MangaParkComic(
|
||||
private val originalStatus: String? = null,
|
||||
private val uploadStatus: String? = null,
|
||||
private val summary: String? = null,
|
||||
private val extraInfo: String? = null,
|
||||
@SerialName("urlCoverOri") private val cover: String? = null,
|
||||
private val urlPath: String,
|
||||
@SerialName("max_chapterNode") private val latestChapter: Data<ImageFiles>? = null,
|
||||
@SerialName("first_chapterNode") private val firstChapter: Data<ImageFiles>? = null,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
fun toSManga(shortenTitle: Boolean, pageAsCover: String) = SManga.create().apply {
|
||||
url = "$urlPath#$id"
|
||||
title = name
|
||||
thumbnail_url = cover
|
||||
title = if (shortenTitle) {
|
||||
var shortName = name
|
||||
while (shortenTitleRegex.containsMatchIn(shortName)) {
|
||||
shortName = shortName.replace(shortenTitleRegex, "").trim()
|
||||
}
|
||||
|
||||
shortName
|
||||
} else {
|
||||
name
|
||||
}
|
||||
thumbnail_url = run {
|
||||
val coverUrl = cover?.let {
|
||||
when {
|
||||
it.startsWith("http") -> it
|
||||
it.startsWith("/") -> "https://$THUMBNAIL_LOOPBACK_HOST$it"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
if (pageAsCover != "off" && useLatestPageAsCover(genres)) {
|
||||
if (pageAsCover == "first") {
|
||||
firstChapter?.data?.imageFile?.urlList?.firstOrNull() ?: coverUrl
|
||||
} else {
|
||||
latestChapter?.data?.imageFile?.urlList?.firstOrNull() ?: coverUrl
|
||||
}
|
||||
} else {
|
||||
coverUrl
|
||||
}
|
||||
}
|
||||
author = authors?.joinToString()
|
||||
artist = artists?.joinToString()
|
||||
description = buildString {
|
||||
val desc = summary?.let { Jsoup.parse(it).text() }
|
||||
val names = altNames?.takeUnless { it.isEmpty() }
|
||||
?.joinToString("\n") { "• ${it.trim()}" }
|
||||
|
||||
if (desc.isNullOrEmpty()) {
|
||||
if (!names.isNullOrEmpty()) {
|
||||
append("Alternative Names:\n", names)
|
||||
}
|
||||
} else {
|
||||
append(desc)
|
||||
if (!names.isNullOrEmpty()) {
|
||||
append("\n\nAlternative Names:\n", names)
|
||||
}
|
||||
if (shortenTitle) {
|
||||
append(name)
|
||||
append("\n\n")
|
||||
}
|
||||
}
|
||||
summary?.also {
|
||||
append(Jsoup.parse(it).wholeText().trim())
|
||||
append("\n\n")
|
||||
}
|
||||
extraInfo?.takeUnless(String::isBlank)?.also {
|
||||
append("Extra Info:\n")
|
||||
append(Jsoup.parse(it).wholeText().trim())
|
||||
append("\n\n")
|
||||
}
|
||||
altNames?.takeUnless(List<String>::isEmpty)
|
||||
?.joinToString(
|
||||
prefix = "Alternative Names:\n",
|
||||
separator = "\n",
|
||||
) { "• ${it.trim()}" }
|
||||
?.also(::append)
|
||||
}.trim()
|
||||
genre = genres?.joinToString { it.replace("_", " ").toCamelCase() }
|
||||
status = when (originalStatus) {
|
||||
status = when (originalStatus ?: uploadStatus) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> {
|
||||
if (uploadStatus == "ongoing") {
|
||||
@ -96,6 +131,14 @@ class MangaParkComic(
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun useLatestPageAsCover(genres: List<String>?): Boolean {
|
||||
return genres.orEmpty().let {
|
||||
it.contains("hentai") && !it.contains("webtoon")
|
||||
}
|
||||
}
|
||||
|
||||
private val shortenTitleRegex = Regex("""^(\[[^]]+\])|^(\([^)]+\))|^(\{[^}]+\})|(\[[^]]+\])${'$'}|(\([^)]+\))${'$'}|(\{[^}]+\})${'$'}""")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,8 +25,23 @@ val SEARCH_QUERY = buildQuery {
|
||||
originalStatus
|
||||
uploadStatus
|
||||
summary
|
||||
extraInfo
|
||||
urlCoverOri
|
||||
urlPath
|
||||
max_chapterNode {
|
||||
data {
|
||||
imageFile {
|
||||
urlList
|
||||
}
|
||||
}
|
||||
}
|
||||
first_chapterNode {
|
||||
data {
|
||||
imageFile {
|
||||
urlList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -52,8 +67,23 @@ val DETAILS_QUERY = buildQuery {
|
||||
originalStatus
|
||||
uploadStatus
|
||||
summary
|
||||
extraInfo
|
||||
urlCoverOri
|
||||
urlPath
|
||||
max_chapterNode {
|
||||
data {
|
||||
imageFile {
|
||||
urlList
|
||||
}
|
||||
}
|
||||
}
|
||||
first_chapterNode {
|
||||
data {
|
||||
imageFile {
|
||||
urlList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ ext {
|
||||
extClass = '.Manhwa18CcFactory'
|
||||
themePkg = 'madara'
|
||||
baseUrl = 'https://manhwa18.cc'
|
||||
overrideVersionCode = 5
|
||||
overrideVersionCode = 6
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@ -28,7 +27,7 @@ class Manhwa18CcEN : Manhwa18Cc("Manhwa18.cc", "https://manhwa18.cc", "en") {
|
||||
|
||||
class Manhwa18CcKO : Manhwa18Cc("Manhwa18.cc", "https://manhwa18.cc", "ko") {
|
||||
override fun popularMangaSelector() = "div.manga-item:has(h3 a[title$='Raw'])"
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/raw/$page")
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/raw/$page", headers)
|
||||
}
|
||||
|
||||
abstract class Manhwa18Cc(
|
||||
@ -45,9 +44,9 @@ abstract class Manhwa18Cc(
|
||||
|
||||
override fun popularMangaNextPageSelector() = "ul.pagination li.next a"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/webtoons/$page?orderby=trending")
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/webtoons/$page?orderby=trending", headers)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/webtoons/$page")
|
||||
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/webtoons/$page", headers)
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
@ -60,7 +59,12 @@ abstract class Manhwa18Cc(
|
||||
// "No results found" message. So this fix redirect to popular page.
|
||||
if (query.isBlank()) return popularMangaRequest(page)
|
||||
|
||||
return GET("$baseUrl/search?q=$query&page=$page")
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override val mangaSubString = "webtoon"
|
||||
@ -72,16 +76,4 @@ abstract class Manhwa18Cc(
|
||||
override fun chapterDateSelector() = "span.chapter-time"
|
||||
|
||||
override val pageListParseSelector = "div.read-content img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(pageListParseSelector).mapIndexed { index, element ->
|
||||
Page(
|
||||
index,
|
||||
document.location(),
|
||||
element?.let {
|
||||
it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
8
src/all/misskon/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'MissKon'
|
||||
extClass = '.MissKon'
|
||||
extVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/misskon/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
src/all/misskon/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/misskon/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
src/all/misskon/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/all/misskon/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,126 @@
|
||||
package eu.kanade.tachiyomi.extension.all.misskon
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.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.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MissKon() : SimpleParsedHttpSource() {
|
||||
|
||||
override val baseUrl = "https://misskon.com"
|
||||
override val lang = "all"
|
||||
override val name = "MissKon"
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 10, 1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun simpleMangaSelector() = "article.item-list"
|
||||
|
||||
override fun simpleMangaFromElement(element: Element): SManga {
|
||||
val titleEL = element.selectFirst(".post-box-title")!!
|
||||
return SManga.create().apply {
|
||||
title = titleEL.text()
|
||||
thumbnail_url = element.selectFirst(".post-thumbnail img")?.absUrl("data-src")
|
||||
setUrlWithoutDomain(titleEL.selectFirst("a")!!.absUrl("href"))
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
}
|
||||
}
|
||||
|
||||
override fun simpleNextPageSelector(): String? = null
|
||||
|
||||
// region popular
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/top3/", headers)
|
||||
// endregion
|
||||
|
||||
// region latest
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/$page", headers)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = ".current + a.page"
|
||||
// endregion
|
||||
|
||||
// region Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val filter = filters.firstInstance<SourceCategorySelector>()
|
||||
return filter.selectedCategory?.let {
|
||||
GET("$baseUrl${it.url}", headers)
|
||||
} ?: run {
|
||||
"$baseUrl/page/$page/".toHttpUrl().newBuilder()
|
||||
.addEncodedQueryParameter("s", query)
|
||||
.build()
|
||||
.let { GET(it, headers) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.content > div.pagination > span.current + a"
|
||||
// endregion
|
||||
|
||||
// region Details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val postInnerEl = document.selectFirst("article > .post-inner")!!
|
||||
return SManga.create().apply {
|
||||
title = postInnerEl.select(".post-title").text()
|
||||
genre = postInnerEl.select(".post-tag > a").joinToString { it.text() }
|
||||
status = SManga.COMPLETED
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val doc = response.asJsoup()
|
||||
val dateUploadStr = doc.selectFirst(".entry img")?.absUrl("data-src")
|
||||
?.let { url ->
|
||||
FULL_DATE_REGEX.find(url)?.groupValues?.get(1)
|
||||
?: YEAR_MONTH_REGEX.find(url)?.groupValues?.get(1)?.let { "$it/01" }
|
||||
}
|
||||
val dateUpload = FULL_DATE_FORMAT.tryParse(dateUploadStr)
|
||||
val maxPage = doc.select("div.page-link:first-of-type a.post-page-numbers").last()?.text()?.toInt() ?: 1
|
||||
val basePageUrl = response.request.url.toString()
|
||||
return (maxPage downTo 1).map { page ->
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain("$basePageUrl/$page")
|
||||
name = "Page $page"
|
||||
date_upload = dateUpload
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("div.post-inner > div.entry > p > img")
|
||||
.mapIndexed { i, imgEl -> Page(i, imageUrl = imgEl.absUrl("data-src")) }
|
||||
}
|
||||
// endregion
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Filter.Header("NOTE: Unable to further search in the category!"),
|
||||
Filter.Separator(),
|
||||
SourceCategorySelector.create(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
private val FULL_DATE_REGEX = Regex("""/(\d{4}/\d{2}/\d{2})/""")
|
||||
private val YEAR_MONTH_REGEX = Regex("""/(\d{4}/\d{2})/""")
|
||||
|
||||
private val FULL_DATE_FORMAT = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
}
|
||||
}
|