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
|
required: true
|
||||||
- label: I have updated all installed extensions.
|
- label: I have updated all installed extensions.
|
||||||
required: true
|
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
|
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).
|
- 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
|
required: true
|
||||||
|
14
README.md
@ -1,14 +1,18 @@
|
|||||||
|
# Keiyoushi Extensions
|
||||||
|
|
||||||
### Please give the repo a :star:
|
### 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) |
|
| [](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).
|
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!
|
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.
|
Issues are up-for-grabs for any developer if there is no assigned user already.
|
||||||
|
|
||||||
# Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome!
|
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")
|
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 eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonNames
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class ResultDto<T>(
|
class ResultDto<T>(
|
||||||
@ -20,19 +28,60 @@ class ResultDto<T>(
|
|||||||
|
|
||||||
fun hasNextPage() = currentPage < lastPage
|
fun hasNextPage() = currentPage < lastPage
|
||||||
|
|
||||||
fun toSMangaList() = (results as List<MangaDto>)
|
fun toSMangaList(): List<SManga> = (results as List<MangaDto>)
|
||||||
.filterNot { it.slug.isNullOrBlank() }.map { it.toSManga() }
|
.map { it.apply { slug = it.slug ?: name.createSlug() } }
|
||||||
}
|
.map(MangaDto::toSManga)
|
||||||
|
|
||||||
@Serializable
|
fun toSChapterList(): List<SChapter> = (results as WrapperChapterDto)
|
||||||
class WrapperDto(
|
.chapters.map {
|
||||||
@SerialName("dataTop")
|
SChapter.create().apply {
|
||||||
val popular: ResultDto<List<MangaDto>>?,
|
name = it.name
|
||||||
@JsonNames("atualizacoesInicial")
|
CHAPTER_NUMBER_REGEX.find(it.name)?.groups?.get(0)?.value?.let {
|
||||||
private val dataLatest: ResultDto<List<MangaDto>>?,
|
chapter_number = it.toFloat()
|
||||||
|
}
|
||||||
|
url = "/capitulo/${it.id}"
|
||||||
|
date_upload = dateFormat.tryParse(it.updateAt)
|
||||||
|
}
|
||||||
|
}.sortedByDescending(SChapter::chapter_number)
|
||||||
|
|
||||||
) {
|
fun toPageList(): List<Page> {
|
||||||
val latest: ResultDto<List<MangaDto>> get() = dataLatest!!
|
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
|
@Serializable
|
||||||
@ -46,7 +95,7 @@ class MangaDto(
|
|||||||
@SerialName("obr_nome")
|
@SerialName("obr_nome")
|
||||||
val name: String,
|
val name: String,
|
||||||
@SerialName("obr_slug")
|
@SerialName("obr_slug")
|
||||||
val slug: String?,
|
var slug: String?,
|
||||||
@SerialName("status")
|
@SerialName("status")
|
||||||
val status: MangaStatus,
|
val status: MangaStatus,
|
||||||
@SerialName("scan_id")
|
@SerialName("scan_id")
|
||||||
@ -105,8 +154,6 @@ class ChapterDto(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
@SerialName("cap_nome")
|
@SerialName("cap_nome")
|
||||||
val name: String,
|
val name: String,
|
||||||
@SerialName("cap_numero")
|
|
||||||
val chapterNumber: Float?,
|
|
||||||
@SerialName("cap_lancado_em")
|
@SerialName("cap_lancado_em")
|
||||||
val updateAt: String,
|
val updateAt: String,
|
||||||
)
|
)
|
||||||
@ -124,7 +171,7 @@ class ChapterPageDto(
|
|||||||
@SerialName("obra")
|
@SerialName("obra")
|
||||||
val manga: MangaReferenceDto,
|
val manga: MangaReferenceDto,
|
||||||
@SerialName("cap_numero")
|
@SerialName("cap_numero")
|
||||||
val chapterNumber: Int,
|
val chapterNumber: Float,
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
class MangaReferenceDto(
|
class MangaReferenceDto(
|
||||||
@ -139,7 +186,16 @@ class ChapterPageDto(
|
|||||||
class PageDto(
|
class PageDto(
|
||||||
val src: String,
|
val src: String,
|
||||||
@SerialName("numero")
|
@SerialName("numero")
|
||||||
val number: Int? = null,
|
val number: Float? = null,
|
||||||
) {
|
) {
|
||||||
fun isWordPressContent(): Boolean = number == 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")
|
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.asObservableSuccess
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
@ -73,10 +74,10 @@ abstract class GroupLe(
|
|||||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request =
|
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 =
|
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 {
|
override fun popularMangaFromElement(element: Element): SManga {
|
||||||
val manga = SManga.create()
|
val manga = SManga.create()
|
||||||
@ -103,15 +104,73 @@ abstract class GroupLe(
|
|||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
val url =
|
val url = "$baseUrl/search/advancedResults?offset=${50 * (page - 1)}"
|
||||||
"$baseUrl/search/advancedResults?offset=${50 * (page - 1)}".toHttpUrl()
|
.toHttpUrl()
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
|
|
||||||
if (query.isNotEmpty()) {
|
if (query.isNotEmpty()) {
|
||||||
url.addQueryParameter("q", query)
|
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)
|
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 {
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
val infoElement = document.select(".expandable").first()!!
|
val infoElement = document.select(".expandable").first()!!
|
||||||
val rawCategory = infoElement.select("span.elem_category").text()
|
val rawCategory = infoElement.select("span.elem_category").text()
|
||||||
|
@ -2,4 +2,4 @@ plugins {
|
|||||||
id("lib-multisrc")
|
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 userId = userIdRegex.find(response.body.string())?.groupValues?.get(1) ?: ""
|
||||||
|
|
||||||
val id = response.request.url.fragment!!
|
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 chapterResponse = client.newCall(GET(chapterUrl, headers)).execute()
|
||||||
|
|
||||||
val data = chapterResponse.parseAs<Post<ChapterListResponse>>()
|
val data = chapterResponse.parseAs<Post<ChapterListResponse>>()
|
||||||
|
@ -2,4 +2,4 @@ plugins {
|
|||||||
id("lib-multisrc")
|
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.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.double
|
import kotlinx.serialization.json.double
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class KemonoFavouritesDto(
|
class KemonoFavouritesDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
@ -25,7 +27,7 @@ class KemonoCreatorDto(
|
|||||||
) {
|
) {
|
||||||
var fav: Long = 0
|
var fav: Long = 0
|
||||||
val updatedDate get() = when {
|
val updatedDate get() = when {
|
||||||
updated.isString -> dateFormat.parse(updated.content)?.time ?: 0
|
updated.isString -> dateFormat.tryParse(updated.content)
|
||||||
else -> (updated.double * 1000).toLong()
|
else -> (updated.double * 1000).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +64,7 @@ class KemonoPostDto(
|
|||||||
private val service: String,
|
private val service: String,
|
||||||
private val user: String,
|
private val user: String,
|
||||||
private val title: String,
|
private val title: String,
|
||||||
private val added: String,
|
private val added: String?,
|
||||||
private val published: String?,
|
private val published: String?,
|
||||||
private val edited: String?,
|
private val edited: String?,
|
||||||
private val file: KemonoFileDto,
|
private val file: KemonoFileDto,
|
||||||
@ -80,13 +82,13 @@ class KemonoPostDto(
|
|||||||
}.distinctBy { it.path }.map { it.toString() }
|
}.distinctBy { it.path }.map { it.toString() }
|
||||||
|
|
||||||
fun toSChapter() = SChapter.create().apply {
|
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"
|
url = "/$service/user/$user/post/$id"
|
||||||
date_upload = postDate?.time ?: 0
|
date_upload = postDate
|
||||||
name = title.ifBlank {
|
name = title.ifBlank {
|
||||||
val postDateString = when {
|
val postDateString = when {
|
||||||
postDate != null && postDate.time != 0L -> chapterNameDateFormat.format(postDate)
|
postDate != 0L -> chapterNameDateFormat.format(postDate)
|
||||||
else -> "unknown date"
|
else -> "unknown date"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ plugins {
|
|||||||
id("lib-multisrc")
|
id("lib-multisrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseVersionCode = 15
|
baseVersionCode = 17
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":lib:i18n"))
|
api(project(":lib:i18n"))
|
||||||
|
@ -59,8 +59,16 @@ abstract class Keyoapp(
|
|||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers)
|
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers)
|
||||||
|
|
||||||
override fun popularMangaSelector(): String =
|
open val popularMangaTitleSelector = listOf(
|
||||||
"div.flex-col div.grid > div.group.border, div:has(h2:contains(Trending)) + div .group.overflow-hidden.grid"
|
"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 {
|
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||||
thumbnail_url = element.getImageUrl("*[style*=background-image]")
|
thumbnail_url = element.getImageUrl("*[style*=background-image]")
|
||||||
@ -243,7 +251,7 @@ abstract class Keyoapp(
|
|||||||
|
|
||||||
override fun chapterListSelector(): String {
|
override fun chapterListSelector(): String {
|
||||||
if (!preferences.showPaidChapters) {
|
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)))"
|
return "#chapters > a:not(:has(.text-sm span:matches(Upcoming)))"
|
||||||
}
|
}
|
||||||
@ -357,6 +365,10 @@ abstract class Keyoapp(
|
|||||||
return now.timeInMillis
|
return now.timeInMillis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selector(selector: String, contains: List<String>): String {
|
||||||
|
return contains.joinToString { selector.replace("%s", it) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
SwitchPreferenceCompat(screen.context).apply {
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
key = SHOW_PAID_CHAPTERS_PREF
|
key = SHOW_PAID_CHAPTERS_PREF
|
||||||
|
@ -2,7 +2,7 @@ plugins {
|
|||||||
id("lib-multisrc")
|
id("lib-multisrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseVersionCode = 41
|
baseVersionCode = 42
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":lib:cryptoaes"))
|
api(project(":lib:cryptoaes"))
|
||||||
|
@ -245,11 +245,13 @@ abstract class Madara(
|
|||||||
val mangaUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
val mangaUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
addPathSegment(mangaSubString)
|
addPathSegment(mangaSubString)
|
||||||
addPathSegment(query.substringAfter(URL_SEARCH_PREFIX))
|
addPathSegment(query.substringAfter(URL_SEARCH_PREFIX))
|
||||||
|
addPathSegment("") // add trailing slash
|
||||||
}.build()
|
}.build()
|
||||||
return client.newCall(GET(mangaUrl, headers))
|
return client.newCall(GET(mangaUrl, headers))
|
||||||
.asObservableSuccess().map { response ->
|
.asObservableSuccess().map { response ->
|
||||||
val manga = mangaDetailsParse(response).apply {
|
val manga = mangaDetailsParse(response).apply {
|
||||||
setUrlWithoutDomain(mangaUrl.toString())
|
setUrlWithoutDomain(mangaUrl.toString())
|
||||||
|
initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
MangasPage(listOf(manga), false)
|
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 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 chapterProtectorSelector = "#chapter-protector-data"
|
||||||
|
open val chapterProtectorPasswordPrefix = "wpmangaprotectornonce='"
|
||||||
|
open val chapterProtectorDataPrefix = "chapter_data='"
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
launchIO { countViews(document) }
|
launchIO { countViews(document) }
|
||||||
@ -992,11 +996,11 @@ abstract class Madara(
|
|||||||
?.let { Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) }
|
?.let { Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) }
|
||||||
?: chapterProtector.html()
|
?: chapterProtector.html()
|
||||||
val password = chapterProtectorHtml
|
val password = chapterProtectorHtml
|
||||||
.substringAfter("wpmangaprotectornonce='")
|
.substringAfter(chapterProtectorPasswordPrefix)
|
||||||
.substringBefore("';")
|
.substringBefore("';")
|
||||||
val chapterData = json.parseToJsonElement(
|
val chapterData = json.parseToJsonElement(
|
||||||
chapterProtectorHtml
|
chapterProtectorHtml
|
||||||
.substringAfter("chapter_data='")
|
.substringAfter(chapterProtectorDataPrefix)
|
||||||
.substringBefore("';")
|
.substringBefore("';")
|
||||||
.replace("\\/", "/"),
|
.replace("\\/", "/"),
|
||||||
).jsonObject
|
).jsonObject
|
||||||
|
@ -2,4 +2,4 @@ plugins {
|
|||||||
id("lib-multisrc")
|
id("lib-multisrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseVersionCode = 6
|
baseVersionCode = 7
|
||||||
|
@ -85,6 +85,9 @@ abstract class MangaBox(
|
|||||||
|
|
||||||
private fun useAltCdnInterceptor(chain: Interceptor.Chain): Response {
|
private fun useAltCdnInterceptor(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
|
if (cdnSet.isEmpty()) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
val requestTag = request.tag(MangaBoxFallBackTag::class.java)
|
val requestTag = request.tag(MangaBoxFallBackTag::class.java)
|
||||||
val originalResponse: Response? = try {
|
val originalResponse: Response? = try {
|
||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
@ -346,11 +349,10 @@ abstract class MangaBox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
val element = document.select("head > script").lastOrNull()
|
val content = document.select("script:containsData(cdns =)").joinToString("\n") { it.data() }
|
||||||
?: return emptyList()
|
|
||||||
val cdns =
|
val cdns =
|
||||||
extractArray(element.html(), "cdns") + extractArray(element.html(), "backupImage")
|
extractArray(content, "cdns") + extractArray(content, "backupImage")
|
||||||
val chapterImages = extractArray(element.html(), "chapterImages")
|
val chapterImages = extractArray(content, "chapterImages")
|
||||||
|
|
||||||
// Add all parsed cdns to set
|
// Add all parsed cdns to set
|
||||||
cdnSet.addAll(cdns)
|
cdnSet.addAll(cdns)
|
||||||
@ -369,6 +371,10 @@ abstract class MangaBox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Page(i, document.location(), parsedUrl)
|
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")
|
id("lib-multisrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseVersionCode = 30
|
baseVersionCode = 34
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":lib:randomua"))
|
//noinspection UseTomlInstead
|
||||||
|
implementation("org.brotli:dec:0.1.2")
|
||||||
}
|
}
|
||||||
|
@ -1,62 +1,71 @@
|
|||||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
|
import android.content.SharedPreferences
|
||||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
import androidx.preference.PreferenceScreen
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
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.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import keiyoushi.utils.getPreferencesLazy
|
||||||
import kotlinx.serialization.decodeFromString
|
import keiyoushi.utils.parseAs
|
||||||
import kotlinx.serialization.json.Json
|
import keiyoushi.utils.tryParse
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.jsoup.nodes.Element
|
import org.brotli.dec.BrotliInputStream
|
||||||
import rx.Observable
|
import java.io.ByteArrayOutputStream
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.text.ParseException
|
import java.net.URLEncoder
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
abstract class MangaHub(
|
abstract class MangaHub(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
final override val baseUrl: String,
|
final override val baseUrl: String,
|
||||||
override val lang: String,
|
override val lang: String,
|
||||||
private val mangaSource: String,
|
private val mangaSource: String,
|
||||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US),
|
private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH),
|
||||||
) : ParsedHttpSource() {
|
) : HttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
private var baseApiUrl = "https://api.mghcdn.com"
|
private val baseApiUrl = "https://api.mghcdn.com"
|
||||||
private var baseCdnUrl = "https://imgx.mghcdn.com"
|
private val baseCdnUrl = "https://imgx.mghcdn.com"
|
||||||
private val regex = Regex("mhub_access=([^;]+)")
|
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()
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
.setRandomUserAgent(
|
|
||||||
userAgentType = UserAgentType.DESKTOP,
|
|
||||||
filterInclude = listOf("chrome"),
|
|
||||||
)
|
|
||||||
.addInterceptor(::apiAuthInterceptor)
|
.addInterceptor(::apiAuthInterceptor)
|
||||||
.rateLimit(1)
|
.addNetworkInterceptor(::compatEncodingInterceptor)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||||
@ -69,60 +78,158 @@ abstract class MangaHub(
|
|||||||
.add("Sec-Fetch-Site", "same-origin")
|
.add("Sec-Fetch-Site", "same-origin")
|
||||||
.add("Upgrade-Insecure-Requests", "1")
|
.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 {
|
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
|
val cookie = client.cookieJar
|
||||||
.loadForRequest(baseUrl.toHttpUrl())
|
.loadForRequest(baseUrl.toHttpUrl())
|
||||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
|
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
|
||||||
|
?: throw MangaHubCookieNotFound()
|
||||||
|
|
||||||
val request =
|
val apiRequest = request.newBuilder()
|
||||||
if (originalRequest.url.toString() == "$baseApiUrl/graphql" && cookie != null) {
|
.header("x-mhub-access", cookie.value)
|
||||||
originalRequest.newBuilder()
|
.build()
|
||||||
.header("x-mhub-access", cookie.value)
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
originalRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
return chain.proceed(request)
|
val response = chain.proceed(apiRequest)
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshApiKey(chapter: SChapter) {
|
val apiResponse = response.peekBody(Long.MAX_VALUE).string()
|
||||||
val slug = "$baseUrl${chapter.url}"
|
.parseAs<ApiResponseError>()
|
||||||
.toHttpUrlOrNull()
|
|
||||||
?.pathSegments
|
|
||||||
?.get(1)
|
|
||||||
|
|
||||||
val url = if (slug != null) {
|
if (apiResponse.errors != null) {
|
||||||
"$baseUrl/manga/$slug".toHttpUrl()
|
response.close() // Avoid leaks
|
||||||
} else {
|
val errors = apiResponse.errors.joinToString("\n") { it.message }
|
||||||
baseUrl.toHttpUrl()
|
throw ApiErrorException(errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
val oldKey = client.cookieJar
|
return response
|
||||||
.loadForRequest(baseUrl.toHttpUrl())
|
}
|
||||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
|
|
||||||
|
|
||||||
for (i in 1..2) {
|
private class MangaHubCookieNotFound : IOException("mhub_access cookie not found")
|
||||||
// Clear key cookie
|
private class ApiErrorException(errorMessage: String) : IOException(errorMessage)
|
||||||
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
|
|
||||||
client.cookieJar.saveFromResponse(url, listOf(cookie))
|
|
||||||
|
|
||||||
// We try requesting again with param if the first one fails
|
private val lock = ReentrantLock()
|
||||||
val query = if (i == 2) "?reloadKey=1" else ""
|
private var refreshed = 0L
|
||||||
|
|
||||||
try {
|
private fun refreshApiKey(refreshUrl: String? = null) {
|
||||||
val response = client.newCall(GET("$url$query", headers)).execute()
|
if (refreshed + 10000 < System.currentTimeMillis() && lock.tryLock()) {
|
||||||
val returnedKey = response.headers["set-cookie"]?.let { regex.find(it)?.groupValues?.get(1) }
|
val url = when {
|
||||||
response.close() // Avoid potential resource leaks
|
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
|
val oldKey = client.cookieJar
|
||||||
} catch (_: IOException) {
|
.loadForRequest(baseUrl.toHttpUrl())
|
||||||
throw IOException("An error occurred while obtaining a new API key") // Show error
|
.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,
|
val signature: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun Element.toSignature(): String {
|
private fun ApiMangaSearchItem.toSignature(): String {
|
||||||
val author = this.select("small").text()
|
val author = this.author
|
||||||
val chNum = this.select(".col-sm-6 a:contains(#)").text()
|
val chNum = this.latestChapter
|
||||||
val genres = this.select(".genre-label").joinToString { it.text() }
|
val genres = this.genres
|
||||||
|
|
||||||
return author + chNum + genres
|
return author + chNum + genres
|
||||||
}
|
}
|
||||||
|
|
||||||
// popular
|
private fun mangaRequest(page: Int, order: String): Request {
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
return postRequestGraphQL(searchQuery(mangaSource, "", "all", order, page))
|
||||||
return GET("$baseUrl/popular/page/$page", headers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// popular
|
||||||
|
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, "POPULAR")
|
||||||
|
|
||||||
// often enough there will be nearly identical entries with slightly different
|
// often enough there will be nearly identical entries with slightly different
|
||||||
// titles, URLs, and image names. in order to cut these "duplicates" down,
|
// titles, URLs, and image names. in order to cut these "duplicates" down,
|
||||||
// assign a "signature" based on author name, chapter number, and genres
|
// 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
|
// if all of those are the same, then it it's the same manga
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
val doc = response.asJsoup()
|
val mangaList = response.parseAs<ApiSearchResponse>()
|
||||||
|
|
||||||
val mangas = doc.select(popularMangaSelector())
|
val mangas = mangaList.data.search.rows.map {
|
||||||
.map {
|
SMangaDTO(
|
||||||
SMangaDTO(
|
"$baseUrl/manga/${it.slug}",
|
||||||
it.select("h4 a").attr("abs:href"),
|
it.title,
|
||||||
it.select("h4 a").text(),
|
"$baseThumbCdnUrl/${it.image}",
|
||||||
it.select("img").attr("abs:src"),
|
it.toSignature(),
|
||||||
it.toSignature(),
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
.distinctBy { it.signature }
|
.distinctBy { it.signature }
|
||||||
.map {
|
.map {
|
||||||
SManga.create().apply {
|
SManga.create().apply {
|
||||||
@ -170,221 +278,171 @@ abstract class MangaHub(
|
|||||||
thumbnail_url = it.thumbnailUrl
|
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
|
// latest
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
return GET("$baseUrl/updates/page/$page", headers)
|
return mangaRequest(page, "LATEST")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
return popularMangaParse(response)
|
return popularMangaParse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
|
||||||
|
|
||||||
// search
|
// search
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
val url = "$baseUrl/search/page/$page".toHttpUrl().newBuilder()
|
var order = "POPULAR"
|
||||||
url.addQueryParameter("q", query)
|
var genres = "all"
|
||||||
|
|
||||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||||
when (filter) {
|
when (filter) {
|
||||||
is OrderBy -> {
|
is OrderBy -> {
|
||||||
val order = filter.values[filter.state]
|
order = filter.values[filter.state].key
|
||||||
url.addQueryParameter("order", order.key)
|
|
||||||
}
|
}
|
||||||
is GenreList -> {
|
is GenreList -> {
|
||||||
val genre = filter.values[filter.state]
|
genres = filter.included.joinToString(",").takeIf { it.isNotBlank() } ?: "all"
|
||||||
url.addQueryParameter("genre", genre.key)
|
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return GET(url.build(), headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
return postRequestGraphQL(searchQuery(mangaSource, query, genres, order, page))
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
return popularMangaParse(response)
|
return popularMangaParse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
|
||||||
|
|
||||||
// manga details
|
// manga details
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
val manga = SManga.create()
|
return postRequestGraphQL(
|
||||||
manga.title = document.select(".breadcrumb .active span").text()
|
mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/")),
|
||||||
manga.author = document.select("div:has(h1) span:contains(Author) + span").first()?.text()
|
refreshUrl = "$baseUrl${manga.url}",
|
||||||
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")
|
|
||||||
|
|
||||||
document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText ->
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
when {
|
val rawManga = response.parseAs<ApiMangaDetailsResponse>()
|
||||||
statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING
|
|
||||||
statusText.contains("completed", true) -> manga.status = SManga.COMPLETED
|
return SManga.create().apply {
|
||||||
else -> manga.status = SManga.UNKNOWN
|
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
|
description = buildString {
|
||||||
document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName ->
|
rawManga.data.manga.description?.let(::append)
|
||||||
if (alternativeName.isNotBlank()) {
|
|
||||||
manga.description = manga.description.orEmpty().let {
|
// Add alternative title
|
||||||
if (it.isBlank()) {
|
val altTitle = rawManga.data.manga.alternativeTitle
|
||||||
"Alternative Name: $alternativeName"
|
if (!altTitle.isNullOrBlank()) {
|
||||||
} else {
|
if (isNotBlank()) append("\n\n")
|
||||||
"$it\n\nAlternative Name: $alternativeName"
|
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> {
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
val document = response.asJsoup()
|
val chapterList = response.parseAs<ApiMangaDetailsResponse>()
|
||||||
val head = document.head()
|
val useGenericTitle = preferences.getUseGenericTitlePref()
|
||||||
return document.select(chapterListSelector()).map { chapterFromElement(it, head) }
|
|
||||||
|
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 generateChapterName(title: String, number: String): String {
|
||||||
|
return if (title.contains(number)) {
|
||||||
private fun chapterFromElement(element: Element, head: Element): SChapter {
|
title
|
||||||
val chapter = SChapter.create()
|
} else if (title.isNotBlank()) {
|
||||||
val potentialLinks = element.select("a[href*='$baseUrl/chapter/']:not([rel*=nofollow]):not([rel*=noreferrer])")
|
"Chapter $number - $title"
|
||||||
var visibleLink = ""
|
} else {
|
||||||
potentialLinks.forEach { a ->
|
generateGenericChapterName(number)
|
||||||
val className = a.className()
|
|
||||||
val styles = head.select("style").html()
|
|
||||||
if (!styles.contains(".$className { display:none; }")) {
|
|
||||||
visibleLink = a.attr("href")
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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 {
|
private fun generateGenericChapterName(number: String): String {
|
||||||
throw UnsupportedOperationException()
|
return "Chapter $number"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/chapter${chapter.url}"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// pages
|
// Pages
|
||||||
override fun pageListRequest(chapter: SChapter): Request {
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
val body = buildJsonObject {
|
val chapterUrl = chapter.url.split("/")
|
||||||
put("query", PAGES_QUERY)
|
|
||||||
put(
|
|
||||||
"variables",
|
|
||||||
buildJsonObject {
|
|
||||||
val chapterUrl = chapter.url.split("/")
|
|
||||||
|
|
||||||
put("mangaSource", mangaSource)
|
return postRequestGraphQL(
|
||||||
put("slug", chapterUrl[2])
|
pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()),
|
||||||
put("number", chapterUrl[3].substringAfter("-").toFloat())
|
refreshUrl = "$baseUrl/chapter${chapter.url}",
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
.toString()
|
|
||||||
.toRequestBody()
|
|
||||||
|
|
||||||
val newHeaders = headersBuilder()
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
.set("Accept", "application/json")
|
val chapterObject = response.parseAs<ApiChapterPagesResponse>()
|
||||||
.set("Content-Type", "application/json")
|
val pages = chapterObject.data.chapter.pages.parseAs<ApiChapterPages>()
|
||||||
.set("Origin", baseUrl)
|
|
||||||
.set("Sec-Fetch-Dest", "empty")
|
// We'll update the cookie here to match the browser's "recently" opened chapter.
|
||||||
.set("Sec-Fetch-Mode", "cors")
|
// This mimics how the browser works and gives us more chance to receive a valid API key upon refresh
|
||||||
.set("Sec-Fetch-Site", "cross-site")
|
val now = Calendar.getInstance().time.time
|
||||||
.removeAll("Upgrade-Insecure-Requests")
|
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()
|
.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>> =
|
// We'll log our action to the site to further increase the chance of valid API key
|
||||||
super.fetchPageList(chapter)
|
val ipRequest = client.newCall(GET("https://api.ipify.org?format=json")).execute()
|
||||||
.doOnError { refreshApiKey(chapter) }
|
val ip = ipRequest.parseAs<PublicIPResponse>().ip
|
||||||
.retry(1)
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
|
client.newCall(GET("$baseUrl/action/logHistory2/${chapterObject.data.chapter.manga.slug}/${chapterObject.data.chapter.chapterNumber}?browserID=$ip")).execute().close()
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
ipRequest.close()
|
||||||
val chapterObject = json.decodeFromString<ApiChapterPagesResponse>(response.body.string())
|
|
||||||
|
|
||||||
if (chapterObject.data?.chapter == null) {
|
return pages.images.mapIndexed { i, page ->
|
||||||
if (chapterObject.errors != null) {
|
Page(i, "", "$baseCdnUrl/${pages.page}$page")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,10 +459,14 @@ abstract class MangaHub(
|
|||||||
return GET(page.url, newHeaders)
|
return GET(page.url, newHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
// filters
|
// 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 {
|
override fun toString(): String {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@ -417,11 +479,14 @@ abstract class MangaHub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class OrderBy(orders: Array<Order>) : Filter.Select<Order>("Order", orders, 0)
|
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(
|
override fun getFilterList() = FilterList(
|
||||||
OrderBy(orderBy),
|
|
||||||
GenreList(genres),
|
GenreList(genres),
|
||||||
|
OrderBy(orderBy),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val orderBy = arrayOf(
|
private val orderBy = arrayOf(
|
||||||
@ -432,70 +497,119 @@ abstract class MangaHub(
|
|||||||
Order("Completed", "COMPLETED"),
|
Order("Completed", "COMPLETED"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val genres = arrayOf(
|
private val genres = listOf(
|
||||||
Genre("All Genres", "all"),
|
|
||||||
Genre("[no chapters]", "no-chapters"),
|
|
||||||
Genre("4-Koma", "4-koma"),
|
|
||||||
Genre("Action", "action"),
|
Genre("Action", "action"),
|
||||||
Genre("Adventure", "adventure"),
|
Genre("Adventure", "adventure"),
|
||||||
Genre("Award Winning", "award-winning"),
|
|
||||||
Genre("Comedy", "comedy"),
|
Genre("Comedy", "comedy"),
|
||||||
Genre("Cooking", "cooking"),
|
Genre("Adult", "adult"),
|
||||||
Genre("Crime", "crime"),
|
|
||||||
Genre("Demons", "demons"),
|
|
||||||
Genre("Doujinshi", "doujinshi"),
|
|
||||||
Genre("Drama", "drama"),
|
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("Historical", "historical"),
|
||||||
Genre("Horror", "horror"),
|
Genre("Martial Arts", "martial-arts"),
|
||||||
Genre("Isekai", "isekai"),
|
Genre("Romance", "romance"),
|
||||||
Genre("Josei", "josei"),
|
Genre("Ecchi", "ecchi"),
|
||||||
Genre("Kids", "kids"),
|
Genre("Supernatural", "supernatural"),
|
||||||
Genre("Magic", "magic"),
|
Genre("Webtoons", "webtoons"),
|
||||||
Genre("Magical Girls", "magical-girls"),
|
|
||||||
Genre("Manhua", "manhua"),
|
|
||||||
Genre("Manhwa", "manhwa"),
|
Genre("Manhwa", "manhwa"),
|
||||||
Genre("Martial arts", "martial-arts"),
|
Genre("Fantasy", "fantasy"),
|
||||||
|
Genre("Harem", "harem"),
|
||||||
|
Genre("Shounen", "shounen"),
|
||||||
|
Genre("Manhua", "manhua"),
|
||||||
Genre("Mature", "mature"),
|
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("Mecha", "mecha"),
|
||||||
Genre("Medical", "medical"),
|
Genre("Medical", "medical"),
|
||||||
Genre("Military", "military"),
|
Genre("Magic", "magic"),
|
||||||
|
Genre("4-Koma", "4-koma"),
|
||||||
Genre("Music", "music"),
|
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("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("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
|
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
class GraphQLTag(
|
||||||
|
val refreshUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
|
val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int ->
|
||||||
|
|
||||||
val PAGES_QUERY = buildQuery {
|
|
||||||
"""
|
"""
|
||||||
query(%mangaSource: MangaSource, %slug: String!, %number: Float!) {
|
{
|
||||||
chapter(x: %mangaSource, slug: %slug, number: %number) {
|
search(x: $mangaSource, q: "$query", genre: "$genre", mod: $order, offset: ${(page - 1) * 30}) {
|
||||||
pages
|
rows {
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
slug,
|
||||||
|
image,
|
||||||
|
genres,
|
||||||
|
latestChapter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
val mangaDetailsQuery = { mangaSource: String, slug: String ->
|
||||||
data class ApiErrorMessages(
|
"""
|
||||||
val message: String,
|
{
|
||||||
)
|
manga(x: $mangaSource, slug: "$slug") {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
status,
|
||||||
|
image,
|
||||||
|
author,
|
||||||
|
artist,
|
||||||
|
genres,
|
||||||
|
description,
|
||||||
|
alternativeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
val mangaChapterListQuery = { mangaSource: String, slug: String ->
|
||||||
data class ApiChapterPagesResponse(
|
"""
|
||||||
val data: ApiChapterData?,
|
{
|
||||||
val errors: List<ApiErrorMessages>?,
|
manga(x: $mangaSource, slug: "$slug") {
|
||||||
)
|
slug,
|
||||||
|
chapters {
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
val pagesQuery = { mangaSource: String, slug: String, number: Float ->
|
||||||
data class ApiChapterData(
|
"""
|
||||||
val chapter: ApiChapter?,
|
{
|
||||||
)
|
chapter(x: $mangaSource, slug: "$slug", number: $number) {
|
||||||
|
pages,
|
||||||
@Serializable
|
mangaID,
|
||||||
data class ApiChapter(
|
number,
|
||||||
val pages: String,
|
manga {
|
||||||
)
|
slug
|
||||||
|
}
|
||||||
@Serializable
|
}
|
||||||
data class ApiChapterPages(
|
}
|
||||||
val p: String,
|
""".trimIndent()
|
||||||
val i: List<String>,
|
}
|
||||||
)
|
|
||||||
|
@ -2,4 +2,4 @@ plugins {
|
|||||||
id("lib-multisrc")
|
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 {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val response = chain.proceed(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
|
// ignore requests that already have completed the JS challenge
|
||||||
if (response.headers["vary"] != null) return response
|
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 {
|
ext {
|
||||||
extName = 'Bato.to'
|
extName = 'Bato.to'
|
||||||
extClass = '.BatoToFactory'
|
extClass = '.BatoToFactory'
|
||||||
extVersionCode = 49
|
extVersionCode = 50
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -279,7 +279,7 @@ open class BatoTo(
|
|||||||
manga.title = infoElement.select("h3").text().removeEntities()
|
manga.title = infoElement.select("h3").text().removeEntities()
|
||||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||||
.attr("abs:src")
|
.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)
|
return MangasPage(listOf(manga), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,7 +405,7 @@ open class BatoTo(
|
|||||||
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
|
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
|
||||||
.select("channel > item").map { item ->
|
.select("channel > item").map { item ->
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = item.selectFirst("guid")!!.text()
|
setUrlWithoutDomain(item.selectFirst("guid")!!.text())
|
||||||
name = item.selectFirst("title")!!.text()
|
name = item.selectFirst("title")!!.text()
|
||||||
date_upload = parseAltChapterDate(item.selectFirst("pubDate")!!.text())
|
date_upload = parseAltChapterDate(item.selectFirst("pubDate")!!.text())
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Buon Dua'
|
extName = 'Buon Dua'
|
||||||
extClass = '.BuonDua'
|
extClass = '.BuonDua'
|
||||||
extVersionCode = 2
|
extVersionCode = 4
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib:randomua"))
|
||||||
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
package eu.kanade.tachiyomi.extension.all.buondua
|
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.GET
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
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.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class BuonDua() : ParsedHttpSource() {
|
class BuonDua() : ParsedHttpSource() {
|
||||||
override val baseUrl = "https://buondua.com"
|
override val baseUrl = "https://buondua.com"
|
||||||
@ -20,6 +27,13 @@ class BuonDua() : ParsedHttpSource() {
|
|||||||
override val name = "Buon Dua"
|
override val name = "Buon Dua"
|
||||||
override val supportsLatest = true
|
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
|
// Latest
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||||
val manga = SManga.create()
|
val manga = SManga.create()
|
||||||
@ -43,10 +57,10 @@ class BuonDua() : ParsedHttpSource() {
|
|||||||
override fun popularMangaRequest(page: Int): Request {
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
return GET("$baseUrl/hot?start=${20 * (page - 1)}")
|
return GET("$baseUrl/hot?start=${20 * (page - 1)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||||
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
@ -57,6 +71,7 @@ class BuonDua() : ParsedHttpSource() {
|
|||||||
else -> popularMangaRequest(page)
|
else -> popularMangaRequest(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaSelector() = latestUpdatesSelector()
|
override fun searchMangaSelector() = latestUpdatesSelector()
|
||||||
|
|
||||||
// Details
|
// Details
|
||||||
@ -72,34 +87,27 @@ class BuonDua() : ParsedHttpSource() {
|
|||||||
return manga
|
return manga
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||||
val chapter = SChapter.create()
|
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
|
||||||
chapter.setUrlWithoutDomain(element.select(".is-current").first()!!.attr("abs:href"))
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
chapter.chapter_number = 0F
|
val doc = response.asJsoup()
|
||||||
chapter.name = element.select(".article-header").text()
|
val dateUploadStr = doc.selectFirst(".article-info > small")?.text()
|
||||||
chapter.date_upload = SimpleDateFormat("H:m DD-MM-yyyy", Locale.US).parse(element.select(".article-info > small").text())?.time ?: 0L
|
val dateUpload = DATE_FORMAT.tryParse(dateUploadStr)
|
||||||
return chapter
|
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 ->
|
||||||
override fun chapterListSelector() = "html"
|
SChapter.create().apply {
|
||||||
|
setUrlWithoutDomain("$basePageUrl?page=$page")
|
||||||
// Pages
|
name = "Page $page"
|
||||||
|
date_upload = dateUpload
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||||
@ -114,4 +122,8 @@ class BuonDua() : ParsedHttpSource() {
|
|||||||
class TagFilter : Filter.Text("Tag ID")
|
class TagFilter : Filter.Text("Tag ID")
|
||||||
|
|
||||||
private inline fun <reified T> Iterable<*>.findInstance() = find { it is T } as? T
|
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_title=Ignored Groups
|
||||||
ignored_groups_summary=Chapters from these groups won't be shown.\nOne group name per line (case-insensitive)
|
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_title=Show Alternative Titles
|
||||||
show_alternative_titles_on=Adds alternative titles to the description
|
show_alternative_titles_on=Adds alternative titles to the description
|
||||||
show_alternative_titles_off=Does not show alternative titles to the description
|
show_alternative_titles_off=Does not show alternative titles to the description
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Comick'
|
extName = 'Comick'
|
||||||
extClass = '.ComickFactory'
|
extClass = '.ComickFactory'
|
||||||
extVersionCode = 55
|
extVersionCode = 56
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,6 +79,12 @@ abstract class Comick(
|
|||||||
}
|
}
|
||||||
}.also(screen::addPreference)
|
}.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 {
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
key = SHOW_ALTERNATIVE_TITLES_PREF
|
key = SHOW_ALTERNATIVE_TITLES_PREF
|
||||||
title = intl["show_alternative_titles_title"]
|
title = intl["show_alternative_titles_title"]
|
||||||
@ -184,6 +190,14 @@ abstract class Comick(
|
|||||||
.orEmpty()
|
.orEmpty()
|
||||||
.toSet()
|
.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
|
private val SharedPreferences.showAlternativeTitles: Boolean
|
||||||
get() = getBoolean(SHOW_ALTERNATIVE_TITLES_PREF, SHOW_ALTERNATIVE_TITLES_DEFAULT)
|
get() = getBoolean(SHOW_ALTERNATIVE_TITLES_PREF, SHOW_ALTERNATIVE_TITLES_DEFAULT)
|
||||||
|
|
||||||
@ -243,8 +257,13 @@ abstract class Comick(
|
|||||||
|
|
||||||
/** Popular Manga **/
|
/** Popular Manga **/
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
val url = "$apiUrl/v1.0/search?sort=follow&limit=$LIMIT&page=$page&tachiyomi=true"
|
return searchMangaRequest(
|
||||||
return GET(url, headers)
|
page = page,
|
||||||
|
query = "",
|
||||||
|
filters = FilterList(
|
||||||
|
SortFilter("follow"),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
@ -257,8 +276,13 @@ abstract class Comick(
|
|||||||
|
|
||||||
/** Latest Manga **/
|
/** Latest Manga **/
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
val url = "$apiUrl/v1.0/search?sort=uploaded&limit=$LIMIT&page=$page&tachiyomi=true"
|
return searchMangaRequest(
|
||||||
return GET(url, headers)
|
page = page,
|
||||||
|
query = "",
|
||||||
|
filters = FilterList(
|
||||||
|
SortFilter("uploaded"),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||||
@ -316,7 +340,7 @@ abstract class Comick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun addTagQueryParameters(builder: Builder, tags: String, parameterName: String) {
|
private fun addTagQueryParameters(builder: Builder, tags: String, parameterName: String) {
|
||||||
tags.split(",").forEach {
|
tags.split(",").filter(String::isNotEmpty).forEach {
|
||||||
builder.addQueryParameter(
|
builder.addQueryParameter(
|
||||||
parameterName,
|
parameterName,
|
||||||
it.trim().lowercase().replace(SPACE_AND_SLASH_REGEX, "-")
|
it.trim().lowercase().replace(SPACE_AND_SLASH_REGEX, "-")
|
||||||
@ -412,6 +436,7 @@ abstract class Comick(
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
addTagQueryParameters(this, preferences.ignoredTags, "excluded-tags")
|
||||||
addQueryParameter("tachiyomi", "true")
|
addQueryParameter("tachiyomi", "true")
|
||||||
addQueryParameter("limit", "$LIMIT")
|
addQueryParameter("limit", "$LIMIT")
|
||||||
addQueryParameter("page", "$page")
|
addQueryParameter("page", "$page")
|
||||||
@ -587,6 +612,7 @@ abstract class Comick(
|
|||||||
const val SLUG_SEARCH_PREFIX = "id:"
|
const val SLUG_SEARCH_PREFIX = "id:"
|
||||||
private val SPACE_AND_SLASH_REGEX = Regex("[ /]")
|
private val SPACE_AND_SLASH_REGEX = Regex("[ /]")
|
||||||
private const val IGNORED_GROUPS_PREF = "IgnoredGroups"
|
private const val IGNORED_GROUPS_PREF = "IgnoredGroups"
|
||||||
|
private const val IGNORED_TAGS_PREF = "IgnoredTags"
|
||||||
private const val SHOW_ALTERNATIVE_TITLES_PREF = "ShowAlternativeTitles"
|
private const val SHOW_ALTERNATIVE_TITLES_PREF = "ShowAlternativeTitles"
|
||||||
const val SHOW_ALTERNATIVE_TITLES_DEFAULT = false
|
const val SHOW_ALTERNATIVE_TITLES_DEFAULT = false
|
||||||
private const val INCLUDE_MU_TAGS_PREF = "IncludeMangaUpdatesTags"
|
private const val INCLUDE_MU_TAGS_PREF = "IncludeMangaUpdatesTags"
|
||||||
|
@ -9,7 +9,7 @@ fun getFilters(): FilterList {
|
|||||||
GenreFilter("Genre", getGenresList),
|
GenreFilter("Genre", getGenresList),
|
||||||
DemographicFilter("Demographic", getDemographicList),
|
DemographicFilter("Demographic", getDemographicList),
|
||||||
TypeFilter("Type", getTypeList),
|
TypeFilter("Type", getTypeList),
|
||||||
SortFilter("Sort", getSortsList),
|
SortFilter(),
|
||||||
StatusFilter("Status", getStatusList),
|
StatusFilter("Status", getStatusList),
|
||||||
ContentRatingFilter("Content Rating", getContentRatingList),
|
ContentRatingFilter("Content Rating", getContentRatingList),
|
||||||
CompletedFilter("Completely Scanlated?"),
|
CompletedFilter("Completely Scanlated?"),
|
||||||
@ -50,8 +50,8 @@ internal class FromYearFilter(name: String) : TextFilter(name)
|
|||||||
|
|
||||||
internal class ToYearFilter(name: String) : TextFilter(name)
|
internal class ToYearFilter(name: String) : TextFilter(name)
|
||||||
|
|
||||||
internal class SortFilter(name: String, sortList: List<Pair<String, String>>, state: Int = 0) :
|
internal class SortFilter(defaultValue: String? = null, state: Int = 0) :
|
||||||
SelectFilter(name, sortList, state)
|
SelectFilter("Sort", getSortsList, state, defaultValue)
|
||||||
|
|
||||||
internal class StatusFilter(name: String, statusList: List<Pair<String, String>>, state: Int = 0) :
|
internal class StatusFilter(name: String, statusList: List<Pair<String, String>>, state: Int = 0) :
|
||||||
SelectFilter(name, statusList, state)
|
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 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) :
|
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(), state) {
|
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: state) {
|
||||||
fun getValue() = vals[state].second
|
fun getValue() = vals[state].second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'DeviantArt'
|
extName = 'DeviantArt'
|
||||||
extClass = '.DeviantArt'
|
extClass = '.DeviantArt'
|
||||||
extVersionCode = 7
|
extVersionCode = 8
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,12 +134,11 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
|||||||
nextUrl = newDocument.selectFirst("[rel=next]")?.absUrl("href")
|
nextUrl = newDocument.selectFirst("[rel=next]")?.absUrl("href")
|
||||||
}
|
}
|
||||||
|
|
||||||
return chapterList.toList().also(::indexChapterList)
|
return chapterList.also(::orderChapterList).toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseToChapterList(document: Document): List<SChapter> {
|
private fun parseToChapterList(document: Document): List<SChapter> {
|
||||||
val items = document.select("item")
|
return document.select("item").map {
|
||||||
return items.map {
|
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
setUrlWithoutDomain(it.selectFirst("link")!!.text())
|
setUrlWithoutDomain(it.selectFirst("link")!!.text())
|
||||||
name = it.selectFirst("title")!!.text()
|
name = it.selectFirst("title")!!.text()
|
||||||
@ -149,17 +148,15 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun indexChapterList(chapterList: List<SChapter>) {
|
private fun orderChapterList(chapterList: MutableList<SChapter>) {
|
||||||
// DeviantArt allows users to arrange galleries arbitrarily so we will
|
// In Mihon's updates tab, chapters are ordered by source instead
|
||||||
// primitively index the list by checking the first and last dates
|
// of chapter number, so to avoid updates being shown in reverse,
|
||||||
if (chapterList.first().date_upload > chapterList.last().date_upload) {
|
// disregard source order and order chronologically instead
|
||||||
chapterList.forEachIndexed { i, chapter ->
|
if (chapterList.first().date_upload < chapterList.last().date_upload) {
|
||||||
chapter.chapter_number = chapterList.size - i.toFloat()
|
chapterList.reverse()
|
||||||
}
|
}
|
||||||
} else {
|
chapterList.forEachIndexed { i, chapter ->
|
||||||
chapterList.forEachIndexed { i, chapter ->
|
chapter.chapter_number = chapterList.size - i.toFloat()
|
||||||
chapter.chapter_number = i.toFloat() + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ ext {
|
|||||||
extClass = '.EternalMangasFactory'
|
extClass = '.EternalMangasFactory'
|
||||||
themePkg = 'mangaesp'
|
themePkg = 'mangaesp'
|
||||||
baseUrl = 'https://eternalmangas.com'
|
baseUrl = 'https://eternalmangas.com'
|
||||||
overrideVersionCode = 2
|
overrideVersionCode = 5
|
||||||
isNsfw = true
|
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.MangaEsp
|
||||||
import eu.kanade.tachiyomi.multisrc.mangaesp.SeriesDto
|
import eu.kanade.tachiyomi.multisrc.mangaesp.SeriesDto
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
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.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import kotlinx.serialization.Serializable
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import rx.Observable
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@ -26,16 +34,58 @@ open class EternalMangas(
|
|||||||
) {
|
) {
|
||||||
override val useApiSearch = true
|
override val useApiSearch = true
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||||
val responseData = json.decodeFromString<LatestUpdatesDto>(response.body.string())
|
return super.fetchSearchManga(page, "", createSortFilter("views", false))
|
||||||
val mangas = responseData.updates[internalLang]?.flatten()?.map { it.toSManga(seriesPath) } ?: emptyList()
|
}
|
||||||
return MangasPage(mangas, false)
|
|
||||||
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||||
|
return super.fetchSearchManga(page, "", createSortFilter("updated_at", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun List<SeriesDto>.additionalParse(): List<SeriesDto> {
|
override fun List<SeriesDto>.additionalParse(): List<SeriesDto> {
|
||||||
return this.filter { it.language == internalLang }.toMutableList()
|
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 {
|
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||||
val body = jsRedirect(response)
|
val body = jsRedirect(response)
|
||||||
|
|
||||||
@ -92,7 +142,7 @@ open class EternalMangas(
|
|||||||
private fun jsRedirect(response: Response): String {
|
private fun jsRedirect(response: Response): String {
|
||||||
var body = response.body.string()
|
var body = response.body.string()
|
||||||
val document = Jsoup.parse(body)
|
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 action = it.attr("action")
|
||||||
val inputs = it.select("input")
|
val inputs = it.select("input")
|
||||||
|
|
||||||
@ -106,8 +156,13 @@ open class EternalMangas(
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
private fun createSortFilter(value: String, ascending: Boolean = false): FilterList {
|
||||||
class LatestUpdatesDto(
|
val sortProperties = getSortProperties()
|
||||||
val updates: Map<String, List<List<SeriesDto>>>,
|
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"?>
|
<?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>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
android:name="eu.kanade.tachiyomi.multisrc.a3manga.A3MangaUrlActivity"
|
android:name=".all.globalcomix.GlobalComixUrlActivity"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.NoDisplay">
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
<intent-filter>
|
<intent-filter
|
||||||
|
android:autoVerify="false"
|
||||||
|
tools:targetApi="23">
|
||||||
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:host="${SOURCEHOST}" />
|
<data android:host="globalcomix.com" />
|
||||||
<data android:host="*.${SOURCEHOST}" />
|
<data android:scheme="https" />
|
||||||
<data android:pathPattern="/truyen-tranh/..*"
|
|
||||||
android:scheme="${SOURCESCHEME}" />
|
<data android:pathPattern="/c/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</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 {
|
ext {
|
||||||
extName = 'Hitomi'
|
extName = 'Hitomi'
|
||||||
extClass = '.HitomiFactory'
|
extClass = '.HitomiFactory'
|
||||||
extVersionCode = 38
|
extVersionCode = 39
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okhttp3.CacheControl
|
import okhttp3.CacheControl
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@ -52,7 +52,7 @@ class Hitomi(
|
|||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
override val client = network.cloudflareClient.newBuilder()
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
.addInterceptor(::updateImageUrlInterceptor)
|
.addInterceptor(::imageUrlInterceptor)
|
||||||
.apply {
|
.apply {
|
||||||
interceptors().add(0, ::streamResetRetry)
|
interceptors().add(0, ::streamResetRetry)
|
||||||
}
|
}
|
||||||
@ -491,18 +491,20 @@ class Hitomi(
|
|||||||
}.awaitAll().filterNotNull()
|
}.awaitAll().filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun Gallery.toSManga() = SManga.create().apply {
|
private fun Gallery.toSManga() = SManga.create().apply {
|
||||||
title = this@toSManga.title
|
title = this@toSManga.title
|
||||||
url = galleryurl
|
url = galleryurl
|
||||||
author = groups?.joinToString { it.formatted } ?: artists?.joinToString { it.formatted }
|
author = groups?.joinToString { it.formatted } ?: artists?.joinToString { it.formatted }
|
||||||
artist = artists?.joinToString { it.formatted }
|
artist = artists?.joinToString { it.formatted }
|
||||||
genre = tags?.joinToString { it.formatted }
|
genre = tags?.joinToString { it.formatted }
|
||||||
thumbnail_url = files.first().let {
|
thumbnail_url = files.first().let {
|
||||||
val hash = it.hash
|
HttpUrl.Builder().apply {
|
||||||
val imageId = imageIdFromHash(hash)
|
scheme("https")
|
||||||
val subDomain = 'a' + subdomainOffset(imageId)
|
host(IMAGE_LOOPBACK_HOST)
|
||||||
|
addQueryParameter(IMAGE_THUMBNAIL, "true")
|
||||||
"https://${subDomain}tn.$cdnDomain/avifbigtn/${thumbPathFromHash(hash)}/$hash.avif"
|
addQueryParameter(IMAGE_GIF, it.isGif.toString())
|
||||||
|
fragment(it.hash)
|
||||||
|
}.toString()
|
||||||
}
|
}
|
||||||
description = buildString {
|
description = buildString {
|
||||||
japaneseTitle?.let {
|
japaneseTitle?.let {
|
||||||
@ -571,11 +573,13 @@ class Hitomi(
|
|||||||
.substringBefore(".")
|
.substringBefore(".")
|
||||||
|
|
||||||
return gallery.files.mapIndexed { idx, img ->
|
return gallery.files.mapIndexed { idx, img ->
|
||||||
// actual logic in updateImageUrlInterceptor
|
// actual logic in imageUrlInterceptor
|
||||||
val imageUrl = "http://127.0.0.1".toHttpUrl().newBuilder()
|
val imageUrl = HttpUrl.Builder().apply {
|
||||||
.fragment(img.hash)
|
scheme("https")
|
||||||
.build()
|
host(IMAGE_LOOPBACK_HOST)
|
||||||
.toString()
|
addQueryParameter(IMAGE_GIF, img.isGif.toString())
|
||||||
|
fragment(img.hash)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
Page(
|
Page(
|
||||||
idx,
|
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()
|
val request = chain.request()
|
||||||
if (request.url.host != "127.0.0.1") {
|
if (request.url.host != IMAGE_LOOPBACK_HOST) {
|
||||||
return chain.proceed(request)
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hash = request.url.fragment!!
|
val hash = request.url.fragment!!
|
||||||
val commonId = runBlocking { commonImageId() }
|
val isThumbnail = request.url.queryParameter(IMAGE_THUMBNAIL) == "true"
|
||||||
val imageId = imageIdFromHash(hash)
|
val isGif = request.url.queryParameter(IMAGE_GIF) == "true"
|
||||||
val subDomain = runBlocking { (subdomainOffset(imageId) + 1) }
|
|
||||||
|
|
||||||
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()
|
val newRequest = request.newBuilder()
|
||||||
.url(imageUrl)
|
.url(imageUrl)
|
||||||
@ -705,3 +729,7 @@ class Hitomi(
|
|||||||
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
|
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||||
override fun imageUrlParse(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
|
@Serializable
|
||||||
class ImageFile(
|
class ImageFile(
|
||||||
val hash: String,
|
val hash: String,
|
||||||
)
|
private val name: String,
|
||||||
|
) {
|
||||||
|
val isGif get() = name.endsWith(".gif")
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Tag(
|
class Tag(
|
||||||
|
@ -3,7 +3,7 @@ ext {
|
|||||||
extClass = '.KdtScans'
|
extClass = '.KdtScans'
|
||||||
themePkg = 'madara'
|
themePkg = 'madara'
|
||||||
baseUrl = 'https://kdtscans.com'
|
baseUrl = 'https://kdtscans.com'
|
||||||
overrideVersionCode = 0
|
overrideVersionCode = 2
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,14 +4,11 @@ import eu.kanade.tachiyomi.multisrc.madara.Madara
|
|||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class KdtScans : Madara(
|
class KdtScans : Madara(
|
||||||
"KDT Scans",
|
"KDT Scans",
|
||||||
"https://kdtscans.com",
|
"https://kdtscans.com",
|
||||||
"all",
|
"all",
|
||||||
dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale("es")),
|
|
||||||
) {
|
) {
|
||||||
override val useNewChapterEndpoint = true
|
override val useNewChapterEndpoint = true
|
||||||
override val fetchGenres = false
|
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 {
|
override fun searchMangaFromElement(element: Element): SManga {
|
||||||
return super.searchMangaFromElement(element).apply {
|
return super.searchMangaFromElement(element).apply {
|
||||||
title = title.cleanupTitle()
|
title = title.cleanupTitle()
|
||||||
@ -37,5 +36,7 @@ class KdtScans : Madara(
|
|||||||
private fun String.cleanupTitle() = replace(titleCleanupRegex, "").trim()
|
private fun String.cleanupTitle() = replace(titleCleanupRegex, "").trim()
|
||||||
|
|
||||||
private val titleCleanupRegex =
|
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 {
|
ext {
|
||||||
extName = 'Kiutaku'
|
extName = 'Kiutaku'
|
||||||
extClass = '.Kiutaku'
|
extClass = '.Kiutaku'
|
||||||
extVersionCode = 2
|
extVersionCode = 3
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +78,12 @@ class Kiutaku : ParsedHttpSource() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
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()
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'SchaleNetwork'
|
extName = 'SchaleNetwork'
|
||||||
extClass = '.KoharuFactory'
|
extClass = '.KoharuFactory'
|
||||||
extVersionCode = 13
|
extVersionCode = 14
|
||||||
isNsfw = true
|
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.extension.all.koharu.Koharu.Companion.token
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@ -120,7 +122,9 @@ class TurnstileInterceptor(
|
|||||||
try {
|
try {
|
||||||
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||||
val authHeaders = authHeaders(authHeader)
|
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 {
|
response.use {
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
with(response) {
|
with(response) {
|
||||||
@ -176,7 +180,9 @@ class TurnstileInterceptor(
|
|||||||
try {
|
try {
|
||||||
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||||
val authHeaders = authHeaders("Bearer $token")
|
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 {
|
response.use {
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
return true
|
return true
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Komga'
|
extName = 'Komga'
|
||||||
extClass = '.KomgaFactory'
|
extClass = '.KomgaFactory'
|
||||||
extVersionCode = 61
|
extVersionCode = 63
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
@ -134,7 +134,12 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
|||||||
else -> "series"
|
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 filterList = filters.ifEmpty { getFilterList() }
|
||||||
val defaultLibraries = defaultLibraries
|
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 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 {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
return if (response.isFromReadList()) {
|
return if (response.isFromReadList()) {
|
||||||
@ -254,7 +259,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
|||||||
.sortedByDescending { it.chapter_number }
|
.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> {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
val pages = response.parseAs<List<PageDto>>()
|
val pages = response.parseAs<List<PageDto>>()
|
||||||
@ -467,17 +472,17 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|
|||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).await().parseAs()
|
libraries = client.newCall(GET("$baseUrl/api/v1/libraries", headers)).await().parseAs()
|
||||||
collections = client
|
collections = client
|
||||||
.newCall(GET("$baseUrl/api/v1/collections?unpaged=true"))
|
.newCall(GET("$baseUrl/api/v1/collections?unpaged=true", headers))
|
||||||
.await()
|
.await()
|
||||||
.parseAs<PageWrapperDto<CollectionDto>>()
|
.parseAs<PageWrapperDto<CollectionDto>>()
|
||||||
.content
|
.content
|
||||||
genres = client.newCall(GET("$baseUrl/api/v1/genres")).await().parseAs()
|
genres = client.newCall(GET("$baseUrl/api/v1/genres", headers)).await().parseAs()
|
||||||
tags = client.newCall(GET("$baseUrl/api/v1/tags")).await().parseAs()
|
tags = client.newCall(GET("$baseUrl/api/v1/tags", headers)).await().parseAs()
|
||||||
publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).await().parseAs()
|
publishers = client.newCall(GET("$baseUrl/api/v1/publishers", headers)).await().parseAs()
|
||||||
authors = client
|
authors = client
|
||||||
.newCall(GET("$baseUrl/api/v1/authors"))
|
.newCall(GET("$baseUrl/api/v1/authors", headers))
|
||||||
.await()
|
.await()
|
||||||
.parseAs<List<AuthorDto>>()
|
.parseAs<List<AuthorDto>>()
|
||||||
.groupBy { it.role }
|
.groupBy { it.role }
|
||||||
|
@ -38,7 +38,7 @@ class SeriesDto(
|
|||||||
metadata.status == "HIATUS" -> SManga.ON_HIATUS
|
metadata.status == "HIATUS" -> SManga.ON_HIATUS
|
||||||
else -> SManga.UNKNOWN
|
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 }
|
description = metadata.summary.ifBlank { booksMetadata.summary }
|
||||||
booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map ->
|
booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map ->
|
||||||
author = map["writer"]?.distinct()?.joinToString()
|
author = map["writer"]?.distinct()?.joinToString()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'MangaFire'
|
extName = 'MangaFire'
|
||||||
extClass = '.MangaFireFactory'
|
extClass = '.MangaFireFactory'
|
||||||
extVersionCode = 11
|
extVersionCode = 12
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,9 @@ class MangaFire(
|
|||||||
|
|
||||||
override val client = network.cloudflareClient.newBuilder().addInterceptor(ImageInterceptor).build()
|
override val client = network.cloudflareClient.newBuilder().addInterceptor(ImageInterceptor).build()
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
|
||||||
// ============================== Popular ===============================
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="https"
|
<data android:scheme="https"
|
||||||
android:host="mangahosted.org"
|
android:host="mangago.fit"
|
||||||
android:pathPattern="/.*/..*" />
|
android:pathPattern="/.*/..*" />
|
||||||
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Manga Hosted'
|
extName = 'Manga Hosted'
|
||||||
extClass = '.MangaHostedFactory'
|
extClass = '.MangaHostedFactory'
|
||||||
extVersionCode = 2
|
extVersionCode = 3
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
|||||||
|
|
||||||
override val name: String = "Manga Hosted${langOption.nameSuffix}"
|
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
|
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> {
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
if (query.startsWith(SEARCH_PREFIX)) {
|
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))
|
return client.newCall(GET(url, headers))
|
||||||
.asObservableSuccess().map { response ->
|
.asObservableSuccess().map { response ->
|
||||||
val mangas = try { listOf(mangaDetailsParse(response)) } catch (_: Exception) { emptyList() }
|
val mangas = try { listOf(mangaDetailsParse(response)) } catch (_: Exception) { emptyList() }
|
||||||
@ -184,7 +184,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
|||||||
title = dto.title
|
title = dto.title
|
||||||
thumbnail_url = dto.thumbnailUrl
|
thumbnail_url = dto.thumbnailUrl
|
||||||
status = dto.status
|
status = dto.status
|
||||||
url = "/${langOption.infix}/${dto.slug}"
|
url = "/${dto.slug}"
|
||||||
genre = dto.genres
|
genre = dto.genres
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
@ -195,7 +195,7 @@ class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SEARCH_PREFIX = "slug:"
|
const val SEARCH_PREFIX = "slug:"
|
||||||
val baseApiUrl = "https://api.novelfull.us"
|
val baseApiUrl = "https://api.mangago.fit"
|
||||||
val apiUrl = "$baseApiUrl/api"
|
val apiUrl = "$baseApiUrl/api"
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
|
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("id", "manga-indo", "id"),
|
||||||
LanguageOption("it", "manga-italia", "manga-it"),
|
LanguageOption("it", "manga-italia", "manga-it"),
|
||||||
LanguageOption("ja", "mangaraw", "raw"),
|
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", "mangaru"),
|
||||||
LanguageOption("ru", "manga-ru-hentai", "hentai", " +18"),
|
LanguageOption("ru", "manga-ru-hentai", "hentai", " +18"),
|
||||||
LanguageOption("ru", "manga-ru-yaoi", "yaoi", " +18 Yaoi"),
|
LanguageOption("ru", "manga-ru-yaoi", "yaoi", " +18 Yaoi"),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'MangaPark'
|
extName = 'MangaPark'
|
||||||
extClass = '.MangaParkFactory'
|
extClass = '.MangaParkFactory'
|
||||||
extVersionCode = 21
|
extVersionCode = 22
|
||||||
isNsfw = true
|
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.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import keiyoushi.utils.firstInstanceOrNull
|
||||||
import keiyoushi.utils.getPreferences
|
import keiyoushi.utils.getPreferences
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.toJsonString
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@ -54,13 +53,15 @@ class MangaPark(
|
|||||||
|
|
||||||
private val apiUrl = "$baseUrl/apo/"
|
private val apiUrl = "$baseUrl/apo/"
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
override val client = network.cloudflareClient.newBuilder().apply {
|
||||||
|
if (preference.getBoolean(ENABLE_NSFW, true)) {
|
||||||
override val client = network.cloudflareClient.newBuilder()
|
addInterceptor(::siteSettingsInterceptor)
|
||||||
.addInterceptor(::siteSettingsInterceptor)
|
addNetworkInterceptor(CookieInterceptor(domain, "nsfw" to "2"))
|
||||||
.addNetworkInterceptor(CookieInterceptor(domain, "nsfw" to "2"))
|
}
|
||||||
.rateLimitHost(apiUrl.toHttpUrl(), 1)
|
rateLimitHost(apiUrl.toHttpUrl(), 1)
|
||||||
.build()
|
// intentionally after rate limit interceptor so thumbnails are not rate limited
|
||||||
|
addInterceptor(::thumbnailDomainInterceptor)
|
||||||
|
}.build()
|
||||||
|
|
||||||
override fun headersBuilder() = super.headersBuilder()
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
.set("Referer", "$baseUrl/")
|
.set("Referer", "$baseUrl/")
|
||||||
@ -96,8 +97,10 @@ class MangaPark(
|
|||||||
|
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
val result = response.parseAs<SearchResponse>()
|
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
|
val hasNextPage = entries.size == size
|
||||||
|
|
||||||
return MangasPage(entries, hasNextPage)
|
return MangasPage(entries, hasNextPage)
|
||||||
@ -164,8 +167,10 @@ class MangaPark(
|
|||||||
|
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
val result = response.parseAs<DetailsResponse>()
|
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("#")
|
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.substringBeforeLast("#")
|
||||||
@ -220,7 +225,7 @@ class MangaPark(
|
|||||||
summary = "%s"
|
summary = "%s"
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, _ ->
|
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
|
true
|
||||||
}
|
}
|
||||||
}.also(screen::addPreference)
|
}.also(screen::addPreference)
|
||||||
@ -231,16 +236,34 @@ class MangaPark(
|
|||||||
summary = "Refresh chapter list to apply changes"
|
summary = "Refresh chapter list to apply changes"
|
||||||
setDefaultValue(false)
|
setDefaultValue(false)
|
||||||
}.also(screen::addPreference)
|
}.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() =
|
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 cookiesNotSet = AtomicBoolean(true)
|
||||||
private val latch = CountDownLatch(1)
|
private val latch = CountDownLatch(1)
|
||||||
@ -271,6 +294,25 @@ class MangaPark(
|
|||||||
return chain.proceed(request)
|
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 {
|
override fun imageUrlParse(response: Response): String {
|
||||||
throw UnsupportedOperationException()
|
throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
@ -298,6 +340,11 @@ class MangaPark(
|
|||||||
"mpark.to",
|
"mpark.to",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private const val ENABLE_NSFW = "pref_nsfw"
|
||||||
private const val DUPLICATE_CHAPTER_PREF_KEY = "pref_dup_chapters"
|
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 originalStatus: String? = null,
|
||||||
private val uploadStatus: String? = null,
|
private val uploadStatus: String? = null,
|
||||||
private val summary: String? = null,
|
private val summary: String? = null,
|
||||||
|
private val extraInfo: String? = null,
|
||||||
@SerialName("urlCoverOri") private val cover: String? = null,
|
@SerialName("urlCoverOri") private val cover: String? = null,
|
||||||
private val urlPath: String,
|
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"
|
url = "$urlPath#$id"
|
||||||
title = name
|
title = if (shortenTitle) {
|
||||||
thumbnail_url = cover
|
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()
|
author = authors?.joinToString()
|
||||||
artist = artists?.joinToString()
|
artist = artists?.joinToString()
|
||||||
description = buildString {
|
description = buildString {
|
||||||
val desc = summary?.let { Jsoup.parse(it).text() }
|
if (shortenTitle) {
|
||||||
val names = altNames?.takeUnless { it.isEmpty() }
|
append(name)
|
||||||
?.joinToString("\n") { "• ${it.trim()}" }
|
append("\n\n")
|
||||||
|
|
||||||
if (desc.isNullOrEmpty()) {
|
|
||||||
if (!names.isNullOrEmpty()) {
|
|
||||||
append("Alternative Names:\n", names)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
append(desc)
|
|
||||||
if (!names.isNullOrEmpty()) {
|
|
||||||
append("\n\nAlternative Names:\n", names)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
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() }
|
genre = genres?.joinToString { it.replace("_", " ").toCamelCase() }
|
||||||
status = when (originalStatus) {
|
status = when (originalStatus ?: uploadStatus) {
|
||||||
"ongoing" -> SManga.ONGOING
|
"ongoing" -> SManga.ONGOING
|
||||||
"completed" -> {
|
"completed" -> {
|
||||||
if (uploadStatus == "ongoing") {
|
if (uploadStatus == "ongoing") {
|
||||||
@ -96,6 +131,14 @@ class MangaParkComic(
|
|||||||
}
|
}
|
||||||
return result.toString()
|
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
|
originalStatus
|
||||||
uploadStatus
|
uploadStatus
|
||||||
summary
|
summary
|
||||||
|
extraInfo
|
||||||
urlCoverOri
|
urlCoverOri
|
||||||
urlPath
|
urlPath
|
||||||
|
max_chapterNode {
|
||||||
|
data {
|
||||||
|
imageFile {
|
||||||
|
urlList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first_chapterNode {
|
||||||
|
data {
|
||||||
|
imageFile {
|
||||||
|
urlList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,8 +67,23 @@ val DETAILS_QUERY = buildQuery {
|
|||||||
originalStatus
|
originalStatus
|
||||||
uploadStatus
|
uploadStatus
|
||||||
summary
|
summary
|
||||||
|
extraInfo
|
||||||
urlCoverOri
|
urlCoverOri
|
||||||
urlPath
|
urlPath
|
||||||
|
max_chapterNode {
|
||||||
|
data {
|
||||||
|
imageFile {
|
||||||
|
urlList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first_chapterNode {
|
||||||
|
data {
|
||||||
|
imageFile {
|
||||||
|
urlList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ ext {
|
|||||||
extClass = '.Manhwa18CcFactory'
|
extClass = '.Manhwa18CcFactory'
|
||||||
themePkg = 'madara'
|
themePkg = 'madara'
|
||||||
baseUrl = 'https://manhwa18.cc'
|
baseUrl = 'https://manhwa18.cc'
|
||||||
overrideVersionCode = 5
|
overrideVersionCode = 6
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.network.GET
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
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") {
|
class Manhwa18CcKO : Manhwa18Cc("Manhwa18.cc", "https://manhwa18.cc", "ko") {
|
||||||
override fun popularMangaSelector() = "div.manga-item:has(h3 a[title$='Raw'])"
|
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(
|
abstract class Manhwa18Cc(
|
||||||
@ -45,9 +44,9 @@ abstract class Manhwa18Cc(
|
|||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "ul.pagination li.next a"
|
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()
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
|
||||||
@ -60,7 +59,12 @@ abstract class Manhwa18Cc(
|
|||||||
// "No results found" message. So this fix redirect to popular page.
|
// "No results found" message. So this fix redirect to popular page.
|
||||||
if (query.isBlank()) return popularMangaRequest(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"
|
override val mangaSubString = "webtoon"
|
||||||
@ -72,16 +76,4 @@ abstract class Manhwa18Cc(
|
|||||||
override fun chapterDateSelector() = "span.chapter-time"
|
override fun chapterDateSelector() = "span.chapter-time"
|
||||||
|
|
||||||
override val pageListParseSelector = "div.read-content img"
|
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)
|
||||||
|
}
|
||||||
|
}
|