Initial commit
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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("Not used")
|
||||
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException("Not used")
|
||||
|
||||
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("Not used")
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not used")
|
||||
|
||||
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("Not used")
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.multisrc.a3manga
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchResponseDto(
|
||||
val data: List<SearchEntryDto>,
|
||||
val success: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchEntryDto(
|
||||
val cstatus: String,
|
||||
val img: String,
|
||||
val isocm: Int,
|
||||
val link: String,
|
||||
val star: Float,
|
||||
val title: String,
|
||||
val vote: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CipherDto(
|
||||
val ciphertext: String,
|
||||
val iv: String,
|
||||
val salt: String,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.multisrc.a3manga
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class A3MangaGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "a3manga"
|
||||
|
||||
override val themeClass = "A3Manga"
|
||||
|
||||
override val baseVersionCode: Int = 3
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("A3 Manga", "https://www.a3manga.xyz", "vi"),
|
||||
SingleLang("Team Lanh Lung", "https://teamlanhlung.me", "vi", sourceName = "Team Lạnh Lùng", overrideVersionCode = 1),
|
||||
SingleLang("Ngon Phong", "https://www.ngonphong.com", "vi", sourceName = "Ngôn Phong", overrideVersionCode = 1),
|
||||
SingleLang("O Cu Meo", "https://www.ocumoe.com", "vi", sourceName = "Ổ Cú Mèo", overrideVersionCode = 1),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
A3MangaGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.multisrc.a3manga
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/*
|
||||
Springboard that accepts https://<domain>/truyen-tranh/$id/ intents
|
||||
*/
|
||||
class A3MangaUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = pathSegments[1]
|
||||
try {
|
||||
startActivity(
|
||||
Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "id:$id")
|
||||
putExtra("filter", packageName)
|
||||
},
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("A3MangaThemeUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("A3MangaThemeUrlActivity", "Could not parse URI from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bakamanga
|
||||
|
||||
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.online.ParsedHttpSource
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.util.Calendar
|
||||
|
||||
abstract class BakaManga(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
override val supportsLatest = true
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/most-views/page/$page", headers)
|
||||
|
||||
override fun popularMangaSelector(): String =
|
||||
".li_truyen"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
title = element.selectFirst(".name")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")!!.absUrl("src")
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? =
|
||||
".page_redirect > a:last-child:not(.active)"
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return if (query.isNotEmpty()) {
|
||||
val url = "$baseUrl/page/$page".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("s", query)
|
||||
GET(url.toString(), headers)
|
||||
} else {
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val genreFilter = filterList.find { it is GenreFilter } as GenreFilter
|
||||
val url = "$baseUrl/category/${genreFilter.toUriPart()}/page/$page"
|
||||
GET(url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String =
|
||||
popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga =
|
||||
popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String? =
|
||||
popularMangaNextPageSelector()
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/latest-updates/page/$page", headers)
|
||||
|
||||
override fun latestUpdatesSelector(): String =
|
||||
popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||
popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? =
|
||||
popularMangaNextPageSelector()
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
val info = document.selectFirst(".box_info")!!
|
||||
title = info.selectFirst("h1")!!.text()
|
||||
artist = info.select(".info-item:contains(Artist:) > a").joinToString { it.text() }
|
||||
|
||||
val descElements = info.select(".story-detail-info:matchText")
|
||||
description = when {
|
||||
descElements.size > 2 -> {
|
||||
descElements.removeFirst() // "Summary:"
|
||||
descElements.removeLast() // "-From example.com"
|
||||
descElements.joinToString("\n") { it.text() }
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val altTitles = info.selectFirst(".info-item:contains(Alternate Title:)")
|
||||
?.text()
|
||||
?.removePrefix("Alternate Title:")
|
||||
?.trim()
|
||||
|
||||
if (altTitles != null && altTitles.isNotEmpty()) {
|
||||
description += "\n\nAlt title(s): $altTitles"
|
||||
}
|
||||
|
||||
genre = info.select(".post-categories > li > a").joinToString { it.text() }
|
||||
status = info.selectFirst(".info-item:contains(Status:)")!!.text()
|
||||
.removePrefix("Status:")
|
||||
.trim()
|
||||
.toStatus()
|
||||
|
||||
thumbnail_url = info.selectFirst(".box_info img")!!.absUrl("src")
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun chapterListParse(response: Response): List<SChapter> =
|
||||
super.chapterListParse(response).reversed()
|
||||
|
||||
override fun chapterListSelector(): String =
|
||||
".list-chapters > .list-chapters > .box_list > .chapter-item"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
name = element.selectFirst(".chap_name")!!.text()
|
||||
chapter_number = name
|
||||
.substringAfter(' ')
|
||||
.substringBefore(' ')
|
||||
.toFloatOrNull() ?: -1f
|
||||
|
||||
date_upload = parseRelativeDate(element.selectFirst(".chap_update")!!.text())
|
||||
}
|
||||
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
date.contains("year") -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||
date.contains("month") -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||
date.contains("week") -> cal.apply { add(Calendar.WEEK_OF_YEAR, -number) }.timeInMillis
|
||||
date.contains("day") -> cal.apply { add(Calendar.DAY_OF_YEAR, -number) }.timeInMillis
|
||||
date.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
date.contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
date.contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("noscript > img").mapIndexed { i, img ->
|
||||
Page(i, document.location(), img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String =
|
||||
""
|
||||
|
||||
// Filter
|
||||
override fun getFilterList() = FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
GenreFilter(getGenreList()),
|
||||
)
|
||||
|
||||
class GenreFilter(vals: Array<Pair<String, String>>) : UriPartFilter("Category", vals)
|
||||
|
||||
abstract fun getGenreList(): Array<Pair<String, String>>
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
// Other
|
||||
private fun String.toStatus() = when (this) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Completed" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bakamanga
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class BakaMangaGenerator : ThemeSourceGenerator {
|
||||
override val themePkg = "bakamanga"
|
||||
|
||||
override val themeClass = "BakaManga"
|
||||
|
||||
override val baseVersionCode = 1
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Manhwa XXL", "https://manhwaxxl.com", "en", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) = BakaMangaGenerator().createAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bakkin
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class BakkinGenerator : ThemeSourceGenerator {
|
||||
override val themePkg = "bakkin"
|
||||
|
||||
override val themeClass = "BakkinReaderX"
|
||||
|
||||
override val baseVersionCode = 6
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Bakkin", "https://bakkin.moe/reader/", "en"),
|
||||
SingleLang("Bakkin Self-hosted", "", "en", className = "BakkinSelfHosted"),
|
||||
SingleLang("UltraLight Scans", "https://readlight.org/", "en", className = "UltraLightScans"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) = BakkinGenerator().createAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bakkin
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Series(
|
||||
val dir: String,
|
||||
val name: String,
|
||||
val author: String?,
|
||||
val status: String?,
|
||||
val thumb: String?,
|
||||
val volumes: List<Volume>,
|
||||
) : Iterable<Chapter> {
|
||||
override fun iterator() = volumes.flatMap { vol ->
|
||||
vol.map {
|
||||
it.copy(
|
||||
name = "$vol - $it",
|
||||
dir = "$dir/${vol.dir}/${it.dir}",
|
||||
)
|
||||
}
|
||||
}.iterator()
|
||||
|
||||
val cover: String
|
||||
get() = thumb ?: "static/nocover.png"
|
||||
|
||||
override fun toString() = name.ifEmpty { dir }
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Volume(
|
||||
val dir: String,
|
||||
val name: String,
|
||||
val chapters: List<Chapter>,
|
||||
) : Iterable<Chapter> by chapters {
|
||||
override fun toString() = name.ifEmpty { dir }
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Chapter(
|
||||
val dir: String,
|
||||
val name: String,
|
||||
val pages: List<String>,
|
||||
) : Iterable<String> by pages {
|
||||
val number: Float
|
||||
get() = dir.substringAfterLast('c').toFloatOrNull() ?: -1f
|
||||
|
||||
override fun toString() = name.ifEmpty { dir }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bakkin
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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 kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
abstract class BakkinReaderX(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
override val supportsLatest = false
|
||||
|
||||
private val userAgent = "Mozilla/5.0 (" +
|
||||
"Android ${Build.VERSION.RELEASE}; Mobile) " +
|
||||
"Tachiyomi/${AppInfo.getVersionName()}"
|
||||
|
||||
protected val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
|
||||
}
|
||||
|
||||
private val json by lazy { Injekt.get<Json>() }
|
||||
|
||||
private val mainUrl: String
|
||||
get() = baseUrl + "main.php" + preferences.getString("quality", "")
|
||||
|
||||
private var seriesCache = emptyList<Series>()
|
||||
|
||||
private fun <R> observableSeries(block: (List<Series>) -> R) =
|
||||
if (seriesCache.isNotEmpty()) {
|
||||
rx.Observable.just(block(seriesCache))!!
|
||||
} else {
|
||||
client.newCall(GET(mainUrl, headers)).asObservableSuccess().map {
|
||||
seriesCache = json.parseToJsonElement(it.body.string())
|
||||
.jsonObject.values.map(json::decodeFromJsonElement)
|
||||
block(seriesCache)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun List<Series>.search(query: String) =
|
||||
if (query.isBlank()) this else filter { it.toString().contains(query, true) }
|
||||
|
||||
override fun headersBuilder() =
|
||||
Headers.Builder().add("User-Agent", userAgent)
|
||||
|
||||
override fun fetchPopularManga(page: Int) =
|
||||
fetchSearchManga(page, "", FilterList())
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
observableSeries { series ->
|
||||
series.search(query).map {
|
||||
SManga.create().apply {
|
||||
url = it.dir
|
||||
title = it.toString()
|
||||
thumbnail_url = baseUrl + it.cover
|
||||
}
|
||||
}.let { MangasPage(it, false) }
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga) =
|
||||
observableSeries { series ->
|
||||
series.first { it.dir == manga.url }.let {
|
||||
SManga.create().apply {
|
||||
url = it.dir
|
||||
title = it.toString()
|
||||
thumbnail_url = baseUrl + it.cover
|
||||
initialized = true
|
||||
author = it.author
|
||||
status = when (it.status) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Completed" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga) =
|
||||
observableSeries { series ->
|
||||
series.first { it.dir == manga.url }.map { chapter ->
|
||||
SChapter.create().apply {
|
||||
url = chapter.dir
|
||||
name = chapter.toString()
|
||||
chapter_number = chapter.number
|
||||
date_upload = 0L
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter) =
|
||||
observableSeries { series ->
|
||||
series.flatten().first { it.dir == chapter.url }
|
||||
.mapIndexed { idx, page -> Page(idx, "", baseUrl + page) }
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = "$baseUrl#m=${manga.url}"
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
val (m, v, c) = chapter.url.split('/')
|
||||
return "$baseUrl#m=$m&v=$v&c=$c"
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = "quality"
|
||||
summary = "%s"
|
||||
title = "Image quality"
|
||||
entries = arrayOf("Original", "Compressed")
|
||||
entryValues = arrayOf("?fullsize", "")
|
||||
setDefaultValue("")
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putString(key, newValue as String).commit()
|
||||
}
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun searchMangaParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun popularMangaParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun chapterListParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun pageListParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun imageUrlParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class Bilibili(
|
||||
override val name: String,
|
||||
final override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::expiredImageTokenIntercept)
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 1)
|
||||
.rateLimitHost(CDN_URL.toHttpUrl(), 2)
|
||||
.rateLimitHost(COVER_CDN_URL.toHttpUrl(), 2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Origin", baseUrl)
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
protected open val intl by lazy { BilibiliIntl(lang) }
|
||||
|
||||
private val apiLang: String = when (lang) {
|
||||
BilibiliIntl.SIMPLIFIED_CHINESE -> "cn"
|
||||
else -> lang
|
||||
}
|
||||
|
||||
protected open val defaultPopularSort: Int = 0
|
||||
|
||||
protected open val defaultLatestSort: Int = 1
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
protected val json: Json by injectLazy()
|
||||
|
||||
protected open val signedIn: Boolean = false
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("", getAllSortOptions(), defaultPopularSort),
|
||||
),
|
||||
)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("", getAllSortOptions(), defaultLatestSort),
|
||||
),
|
||||
)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
ID_SEARCH_PATTERN.matchEntire(query)?.let {
|
||||
val (id) = it.destructured
|
||||
val temporaryManga = SManga.create().apply { url = "/detail/mc$id" }
|
||||
return mangaDetailsRequest(temporaryManga)
|
||||
}
|
||||
|
||||
val price = filters.firstInstanceOrNull<PriceFilter>()?.state ?: 0
|
||||
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("area_id", filters.firstInstanceOrNull<AreaFilter>()?.selected?.id ?: -1)
|
||||
put("is_finish", filters.firstInstanceOrNull<StatusFilter>()?.state?.minus(1) ?: -1)
|
||||
put("is_free", if (price == 0) -1 else price)
|
||||
put("order", filters.firstInstanceOrNull<SortFilter>()?.selected?.id ?: 0)
|
||||
put("page_num", page)
|
||||
put("page_size", if (query.isBlank()) POPULAR_PER_PAGE else SEARCH_PER_PAGE)
|
||||
put("style_id", filters.firstInstanceOrNull<GenreFilter>()?.selected?.id ?: -1)
|
||||
put("style_prefer", "[]")
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
put("need_shield_prefer", true)
|
||||
put("key_word", query)
|
||||
}
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val refererUrl = if (query.isBlank()) {
|
||||
"$baseUrl/genre"
|
||||
} else {
|
||||
"$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("keyword", query)
|
||||
.toString()
|
||||
}
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", refererUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/".toHttpUrl().newBuilder()
|
||||
.addPathSegment(if (query.isBlank()) "ClassPage" else "Search")
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val requestUrl = response.request.url.toString()
|
||||
if (requestUrl.contains("ComicDetail")) {
|
||||
val comic = mangaDetailsParse(response)
|
||||
return MangasPage(listOf(comic), hasNextPage = false)
|
||||
}
|
||||
|
||||
if (requestUrl.contains("ClassPage")) {
|
||||
val result = response.parseAs<List<BilibiliComicDto>>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return MangasPage(emptyList(), hasNextPage = false)
|
||||
}
|
||||
|
||||
val comicList = result.data!!.map(::searchMangaFromObject)
|
||||
val hasNextPage = comicList.size == POPULAR_PER_PAGE
|
||||
|
||||
return MangasPage(comicList, hasNextPage)
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliSearchDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return MangasPage(emptyList(), hasNextPage = false)
|
||||
}
|
||||
|
||||
val comicList = result.data!!.list.map(::searchMangaFromObject)
|
||||
val hasNextPage = comicList.size == SEARCH_PER_PAGE
|
||||
|
||||
return MangasPage(comicList, hasNextPage)
|
||||
}
|
||||
|
||||
private fun searchMangaFromObject(comic: BilibiliComicDto): SManga = SManga.create().apply {
|
||||
title = Jsoup.parse(comic.title).text()
|
||||
thumbnail_url = comic.verticalCover + THUMBNAIL_RESOLUTION
|
||||
|
||||
val comicId = if (comic.id == 0) comic.seasonId else comic.id
|
||||
url = "/detail/mc$comicId"
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String = baseUrl + manga.url
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val comicId = manga.url.substringAfterLast("/mc").toInt()
|
||||
|
||||
val jsonPayload = buildJsonObject { put("comic_id", comicId) }
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl + manga.url)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/ComicDetail".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
|
||||
val comic = response.parseAs<BilibiliComicDto>().data!!
|
||||
|
||||
title = comic.title
|
||||
author = comic.authorName.joinToString()
|
||||
genre = comic.styles.joinToString()
|
||||
status = when {
|
||||
comic.isFinish == 1 -> SManga.COMPLETED
|
||||
comic.isOnHiatus -> SManga.ON_HIATUS
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
description = buildString {
|
||||
if (comic.hasPaidChapters && !signedIn) {
|
||||
append("${intl.hasPaidChaptersWarning(comic.paidChaptersCount)}\n\n")
|
||||
}
|
||||
|
||||
append(comic.classicLines)
|
||||
|
||||
if (comic.updateWeekdays.isNotEmpty() && status == SManga.ONGOING) {
|
||||
append("\n\n${intl.informationTitle}:")
|
||||
append("\n• ${intl.getUpdateDays(comic.updateWeekdays)}")
|
||||
}
|
||||
}
|
||||
thumbnail_url = comic.verticalCover
|
||||
url = "/detail/mc" + comic.id
|
||||
}
|
||||
|
||||
// Chapters are available in the same url of the manga details.
|
||||
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<BilibiliComicDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return result.data!!.episodeList.map { ep -> chapterFromObject(ep, result.data.id) }
|
||||
}
|
||||
|
||||
protected open fun chapterFromObject(episode: BilibiliEpisodeDto, comicId: Int, isUnlocked: Boolean = false): SChapter = SChapter.create().apply {
|
||||
name = buildString {
|
||||
if (episode.isPaid && !isUnlocked) {
|
||||
append("$EMOJI_LOCKED ")
|
||||
}
|
||||
|
||||
append(episode.shortTitle)
|
||||
|
||||
if (episode.title.isNotBlank()) {
|
||||
append(" - ${episode.title}")
|
||||
}
|
||||
}
|
||||
date_upload = episode.publicationTime.toDate()
|
||||
url = "/mc$comicId/${episode.id}"
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request = imageIndexRequest(chapter.url, "")
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> = imageIndexParse(response)
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
protected open fun imageIndexRequest(chapterUrl: String, credential: String): Request {
|
||||
val chapterId = chapterUrl.substringAfterLast("/").toInt()
|
||||
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("credential", credential)
|
||||
put("ep_id", chapterId)
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl + chapterUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/GetImageIndex".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
protected open fun imageIndexParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<BilibiliReader>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val imageQuality = preferences.chapterImageQuality
|
||||
val imageFormat = preferences.chapterImageFormat
|
||||
|
||||
val imageUrls = result.data!!.images.map { it.url(imageQuality, imageFormat) }
|
||||
val imageTokenRequest = imageTokenRequest(imageUrls)
|
||||
val imageTokenResponse = client.newCall(imageTokenRequest).execute()
|
||||
val imageTokenResult = imageTokenResponse.parseAs<List<BilibiliPageDto>>()
|
||||
|
||||
return imageTokenResult.data!!
|
||||
.mapIndexed { i, page -> Page(i, "", "${page.url}?token=${page.token}") }
|
||||
}
|
||||
|
||||
protected open fun imageTokenRequest(urls: List<String>): Request {
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("urls", json.encodeToString(urls))
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/ImageToken".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, headers, requestBody)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val imageQualityPref = ListPreference(screen.context).apply {
|
||||
key = "${IMAGE_QUALITY_PREF_KEY}_$lang"
|
||||
title = intl.imageQualityPrefTitle
|
||||
entries = intl.imageQualityPrefEntries
|
||||
entryValues = IMAGE_QUALITY_PREF_ENTRY_VALUES
|
||||
setDefaultValue(IMAGE_QUALITY_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}
|
||||
|
||||
val imageFormatPref = ListPreference(screen.context).apply {
|
||||
key = "${IMAGE_FORMAT_PREF_KEY}_$lang"
|
||||
title = intl.imageFormatPrefTitle
|
||||
entries = IMAGE_FORMAT_PREF_ENTRIES
|
||||
entryValues = IMAGE_FORMAT_PREF_ENTRY_VALUES
|
||||
setDefaultValue(IMAGE_FORMAT_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}
|
||||
|
||||
screen.addPreference(imageQualityPref)
|
||||
screen.addPreference(imageFormatPref)
|
||||
}
|
||||
|
||||
abstract fun getAllGenres(): Array<BilibiliTag>
|
||||
|
||||
protected open fun getAllAreas(): Array<BilibiliTag> = emptyArray()
|
||||
|
||||
protected open fun getAllSortOptions(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag(intl.sortInterest, 0),
|
||||
BilibiliTag(intl.sortUpdated, 4),
|
||||
)
|
||||
|
||||
protected open fun getAllStatus(): Array<String> =
|
||||
arrayOf(intl.statusAll, intl.statusOngoing, intl.statusComplete)
|
||||
|
||||
protected open fun getAllPrices(): Array<String> = emptyArray()
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val allAreas = getAllAreas()
|
||||
val allPrices = getAllPrices()
|
||||
|
||||
val filters = listOfNotNull(
|
||||
StatusFilter(intl.statusLabel, getAllStatus()),
|
||||
SortFilter(intl.sortLabel, getAllSortOptions(), defaultPopularSort),
|
||||
PriceFilter(intl.priceLabel, getAllPrices()).takeIf { allPrices.isNotEmpty() },
|
||||
GenreFilter(intl.genreLabel, getAllGenres()),
|
||||
AreaFilter(intl.areaLabel, allAreas).takeIf { allAreas.isNotEmpty() },
|
||||
)
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
private fun expiredImageTokenIntercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Get a new image token if the current one expired.
|
||||
if (response.code == 403 && chain.request().url.toString().contains(CDN_URL)) {
|
||||
response.close()
|
||||
val imagePath = chain.request().url.toString()
|
||||
.substringAfter(CDN_URL)
|
||||
.substringBefore("?token=")
|
||||
val imageTokenRequest = imageTokenRequest(listOf(imagePath))
|
||||
val imageTokenResponse = chain.proceed(imageTokenRequest)
|
||||
val imageTokenResult = imageTokenResponse.parseAs<List<BilibiliPageDto>>()
|
||||
imageTokenResponse.close()
|
||||
|
||||
val newPage = imageTokenResult.data!!.first()
|
||||
val newPageUrl = "${newPage.url}?token=${newPage.token}"
|
||||
|
||||
val newRequest = imageRequest(Page(0, "", newPageUrl))
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private val SharedPreferences.chapterImageQuality
|
||||
get() = when (getString("${IMAGE_QUALITY_PREF_KEY}_$lang", IMAGE_QUALITY_PREF_DEFAULT_VALUE)!!) {
|
||||
"hd" -> "1600w"
|
||||
"sd" -> "1000w"
|
||||
"low" -> "800w_50q"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
private val SharedPreferences.chapterImageFormat
|
||||
get() = getString("${IMAGE_FORMAT_PREF_KEY}_$lang", IMAGE_FORMAT_PREF_DEFAULT_VALUE)!!
|
||||
|
||||
private inline fun <reified R> List<*>.firstInstanceOrNull(): R? = firstOrNull { it is R } as? R
|
||||
|
||||
protected open fun HttpUrl.Builder.addCommonParameters(): HttpUrl.Builder = apply {
|
||||
if (name == "BILIBILI COMICS") {
|
||||
addQueryParameter("lang", apiLang)
|
||||
addQueryParameter("sys_lang", apiLang)
|
||||
}
|
||||
|
||||
addQueryParameter("device", "pc")
|
||||
addQueryParameter("platform", "web")
|
||||
}
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): BilibiliResultDto<T> = use {
|
||||
json.decodeFromString(it.body.string())
|
||||
}
|
||||
|
||||
private fun String.toDate(): Long {
|
||||
return runCatching { DATE_FORMATTER.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CDN_URL = "https://manga.hdslb.com"
|
||||
const val COVER_CDN_URL = "https://i0.hdslb.com"
|
||||
|
||||
const val API_COMIC_V1_COMIC_ENDPOINT = "twirp/comic.v1.Comic"
|
||||
|
||||
private const val ACCEPT_JSON = "application/json, text/plain, */*"
|
||||
|
||||
val JSON_MEDIA_TYPE = "application/json;charset=UTF-8".toMediaType()
|
||||
|
||||
private const val POPULAR_PER_PAGE = 18
|
||||
private const val SEARCH_PER_PAGE = 9
|
||||
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
private val ID_SEARCH_PATTERN = "^${PREFIX_ID_SEARCH}mc(\\d+)$".toRegex()
|
||||
|
||||
private const val IMAGE_QUALITY_PREF_KEY = "chapterImageQuality"
|
||||
private val IMAGE_QUALITY_PREF_ENTRY_VALUES = arrayOf("raw", "hd", "sd", "low")
|
||||
private val IMAGE_QUALITY_PREF_DEFAULT_VALUE = IMAGE_QUALITY_PREF_ENTRY_VALUES[1]
|
||||
|
||||
private const val IMAGE_FORMAT_PREF_KEY = "chapterImageFormat"
|
||||
private val IMAGE_FORMAT_PREF_ENTRIES = arrayOf("JPG", "WEBP", "PNG")
|
||||
private val IMAGE_FORMAT_PREF_ENTRY_VALUES = arrayOf("jpg", "webp", "png")
|
||||
private val IMAGE_FORMAT_PREF_DEFAULT_VALUE = IMAGE_FORMAT_PREF_ENTRY_VALUES[0]
|
||||
|
||||
const val THUMBNAIL_RESOLUTION = "@512w.jpg"
|
||||
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
private const val EMOJI_LOCKED = "\uD83D\uDD12"
|
||||
const val EMOJI_WARNING = "\u26A0\uFE0F"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BilibiliResultDto<T>(
|
||||
val code: Int = 0,
|
||||
val data: T? = null,
|
||||
@SerialName("msg") val message: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliSearchDto(
|
||||
val list: List<BilibiliComicDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliComicDto(
|
||||
@SerialName("author_name") val authorName: List<String> = emptyList(),
|
||||
@SerialName("classic_lines") val classicLines: String = "",
|
||||
@SerialName("comic_id") val comicId: Int = 0,
|
||||
@SerialName("ep_list") val episodeList: List<BilibiliEpisodeDto> = emptyList(),
|
||||
val id: Int = 0,
|
||||
@SerialName("is_finish") val isFinish: Int = 0,
|
||||
@SerialName("temp_stop_update") val isOnHiatus: Boolean = false,
|
||||
@SerialName("season_id") val seasonId: Int = 0,
|
||||
val styles: List<String> = emptyList(),
|
||||
val title: String,
|
||||
@SerialName("update_weekday") val updateWeekdays: List<Int> = emptyList(),
|
||||
@SerialName("vertical_cover") val verticalCover: String = "",
|
||||
) {
|
||||
val hasPaidChapters: Boolean
|
||||
get() = paidChaptersCount > 0
|
||||
|
||||
val paidChaptersCount: Int
|
||||
get() = episodeList.filter { it.isPaid }.size
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliEpisodeDto(
|
||||
val id: Int,
|
||||
@SerialName("is_in_free") val isInFree: Boolean,
|
||||
@SerialName("is_locked") val isLocked: Boolean,
|
||||
@SerialName("pay_gold") val payGold: Int,
|
||||
@SerialName("pay_mode") val payMode: Int,
|
||||
@SerialName("pub_time") val publicationTime: String,
|
||||
@SerialName("short_title") val shortTitle: String,
|
||||
val title: String,
|
||||
) {
|
||||
val isPaid = payMode == 1 && payGold > 0
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliReader(
|
||||
val images: List<BilibiliImageDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliImageDto(
|
||||
val path: String,
|
||||
@SerialName("x") val width: Int,
|
||||
@SerialName("y") val height: Int,
|
||||
) {
|
||||
|
||||
fun url(quality: String, format: String): String {
|
||||
val imageWidth = if (quality == "raw") "${width}w" else quality
|
||||
|
||||
return "$path@$imageWidth.$format"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliPageDto(
|
||||
val token: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliAccessTokenCookie(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val area: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliAccessToken(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliUserEpisodes(
|
||||
@SerialName("unlocked_eps") val unlockedEpisodes: List<BilibiliUnlockedEpisode>? = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliUnlockedEpisode(
|
||||
@SerialName("ep_id") val id: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliGetCredential(
|
||||
@SerialName("comic_id") val comicId: Int,
|
||||
@SerialName("ep_id") val episodeId: Int,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliCredential(
|
||||
val credential: String,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
data class BilibiliTag(val name: String, val id: Int) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
open class EnhancedSelect<T>(name: String, values: Array<T>, state: Int = 0) :
|
||||
Filter.Select<T>(name, values, state) {
|
||||
|
||||
val selected: T?
|
||||
get() = values.getOrNull(state)
|
||||
}
|
||||
|
||||
class GenreFilter(label: String, genres: Array<BilibiliTag>) :
|
||||
EnhancedSelect<BilibiliTag>(label, genres)
|
||||
|
||||
class AreaFilter(label: String, genres: Array<BilibiliTag>) :
|
||||
EnhancedSelect<BilibiliTag>(label, genres)
|
||||
|
||||
class SortFilter(label: String, options: Array<BilibiliTag>, state: Int = 0) :
|
||||
EnhancedSelect<BilibiliTag>(label, options, state)
|
||||
|
||||
class StatusFilter(label: String, statuses: Array<String>) :
|
||||
Filter.Select<String>(label, statuses)
|
||||
|
||||
class PriceFilter(label: String, prices: Array<String>) :
|
||||
Filter.Select<String>(label, prices)
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class BilibiliGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "bilibili"
|
||||
|
||||
override val themeClass = "Bilibili"
|
||||
|
||||
override val baseVersionCode: Int = 9
|
||||
|
||||
override val sources = listOf(
|
||||
MultiLang(
|
||||
name = "BILIBILI COMICS",
|
||||
baseUrl = "https://www.bilibilicomics.com",
|
||||
langs = listOf("en", "zh-Hans", "id", "es", "fr"),
|
||||
className = "BilibiliComicsFactory",
|
||||
overrideVersionCode = 3,
|
||||
),
|
||||
SingleLang(
|
||||
name = "BILIBILI MANGA",
|
||||
baseUrl = "https://manga.bilibili.com",
|
||||
lang = "zh-Hans",
|
||||
className = "BilibiliManga",
|
||||
sourceName = "哔哩哔哩漫画",
|
||||
overrideVersionCode = 2,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
BilibiliGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import java.text.DateFormatSymbols
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BilibiliIntl(private val lang: String) {
|
||||
|
||||
private val locale by lazy { Locale.forLanguageTag(lang) }
|
||||
|
||||
private val dateFormatSymbols by lazy { DateFormatSymbols(locale) }
|
||||
|
||||
private val numberFormat by lazy { NumberFormat.getInstance(locale) }
|
||||
|
||||
val statusLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "进度"
|
||||
SPANISH -> "Estado"
|
||||
else -> "Status"
|
||||
}
|
||||
|
||||
val sortLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "排序"
|
||||
INDONESIAN -> "Urutkan dengan"
|
||||
SPANISH -> "Ordenar por"
|
||||
else -> "Sort by"
|
||||
}
|
||||
|
||||
val genreLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "题材"
|
||||
SPANISH -> "Género"
|
||||
else -> "Genre"
|
||||
}
|
||||
|
||||
val areaLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "地区"
|
||||
else -> "Area"
|
||||
}
|
||||
|
||||
val priceLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "收费"
|
||||
INDONESIAN -> "Harga"
|
||||
SPANISH -> "Precio"
|
||||
else -> "Price"
|
||||
}
|
||||
|
||||
fun hasPaidChaptersWarning(chapterCount: Int): String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE ->
|
||||
"${Bilibili.EMOJI_WARNING} 此漫画有 ${chapterCount.localized} 个付费章节,已在目录中隐藏。" +
|
||||
"如果你已购买,请在 WebView 登录并刷新目录,即可阅读已购章节。"
|
||||
SPANISH ->
|
||||
"${Bilibili.EMOJI_WARNING} ADVERTENCIA: Esta serie tiene ${chapterCount.localized} " +
|
||||
"capítulos pagos que fueron filtrados de la lista de capítulos. Si ya has " +
|
||||
"desbloqueado y tiene alguno en su cuenta, inicie sesión en WebView y " +
|
||||
"actualice la lista de capítulos para leerlos."
|
||||
else ->
|
||||
"${Bilibili.EMOJI_WARNING} WARNING: This series has ${chapterCount.localized} paid " +
|
||||
"chapters. If you have any unlocked in your account then sign in through WebView " +
|
||||
"to be able to read them."
|
||||
}
|
||||
|
||||
val imageQualityPrefTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "章节图片质量"
|
||||
INDONESIAN -> "Kualitas gambar"
|
||||
SPANISH -> "Calidad de imagen del capítulo"
|
||||
else -> "Chapter image quality"
|
||||
}
|
||||
|
||||
val imageQualityPrefEntries: Array<String> = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> arrayOf("原图", "高清 (1600w)", "标清 (1000w)", "低清 (800w)")
|
||||
else -> arrayOf("Raw", "HD (1600w)", "SD (1000w)", "Low (800w)")
|
||||
}
|
||||
|
||||
val imageFormatPrefTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "章节图片格式"
|
||||
INDONESIAN -> "Format gambar"
|
||||
SPANISH -> "Formato de la imagen del capítulo"
|
||||
else -> "Chapter image format"
|
||||
}
|
||||
|
||||
val sortInterest: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "为你推荐"
|
||||
INDONESIAN -> "Kamu Mungkin Suka"
|
||||
SPANISH -> "Sugerencia"
|
||||
else -> "Interest"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortPopular: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "人气推荐"
|
||||
INDONESIAN -> "Populer"
|
||||
SPANISH -> "Popularidad"
|
||||
FRENCH -> "Préférences"
|
||||
else -> "Popular"
|
||||
}
|
||||
|
||||
val sortUpdated: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "更新时间"
|
||||
INDONESIAN -> "Terbaru"
|
||||
SPANISH -> "Actualización"
|
||||
FRENCH -> "Récent"
|
||||
else -> "Updated"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortAdded: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "上架时间"
|
||||
else -> "Added"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortFollowers: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "追漫人数"
|
||||
else -> "Followers count"
|
||||
}
|
||||
|
||||
val statusAll: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "全部"
|
||||
INDONESIAN -> "Semua"
|
||||
SPANISH -> "Todos"
|
||||
FRENCH -> "Tout"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
val statusOngoing: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "连载中"
|
||||
INDONESIAN -> "Berlangsung"
|
||||
SPANISH -> "En curso"
|
||||
FRENCH -> "En cours"
|
||||
else -> "Ongoing"
|
||||
}
|
||||
|
||||
val statusComplete: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "已完结"
|
||||
INDONESIAN -> "Tamat"
|
||||
SPANISH -> "Finalizado"
|
||||
FRENCH -> "Complet"
|
||||
else -> "Completed"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceAll: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "全部"
|
||||
INDONESIAN -> "Semua"
|
||||
SPANISH -> "Todos"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceFree: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "免费"
|
||||
INDONESIAN -> "Bebas"
|
||||
SPANISH -> "Gratis"
|
||||
else -> "Free"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val pricePaid: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "付费"
|
||||
INDONESIAN -> "Dibayar"
|
||||
SPANISH -> "Pago"
|
||||
else -> "Paid"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceWaitForFree: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "等就免费"
|
||||
else -> "Wait for free"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
val failedToRefreshToken: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "无法刷新令牌。请打开 WebView 修正错误。"
|
||||
SPANISH -> "Error al actualizar el token. Abra el WebView para solucionar este error."
|
||||
else -> "Failed to refresh the token. Open the WebView to fix this error."
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
val failedToGetCredential: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "无法获取阅读章节所需的凭证。"
|
||||
SPANISH -> "Erro al obtener la credencial para leer el capítulo."
|
||||
else -> "Failed to get the credential to read the chapter."
|
||||
}
|
||||
|
||||
val informationTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "信息"
|
||||
SPANISH -> "Información"
|
||||
else -> "Information"
|
||||
}
|
||||
|
||||
private val updatesDaily: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "每日更新"
|
||||
SPANISH -> "Actualizaciones diarias"
|
||||
else -> "Updates daily"
|
||||
}
|
||||
|
||||
private fun updatesEvery(days: String): String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "${days}更新"
|
||||
SPANISH -> "Actualizaciones todos los $days"
|
||||
else -> "Updates every $days"
|
||||
}
|
||||
|
||||
fun getUpdateDays(dayIndexes: List<Int>): String {
|
||||
val shortWeekDays = dateFormatSymbols.shortWeekdays.filterNot(String::isBlank)
|
||||
if (dayIndexes.size == shortWeekDays.size) return updatesDaily
|
||||
val shortWeekDaysUpperCased = shortWeekDays.map {
|
||||
it.replaceFirstChar { char -> char.uppercase(locale) }
|
||||
}
|
||||
|
||||
val days = dayIndexes.joinToString { shortWeekDaysUpperCased[it] }
|
||||
return updatesEvery(days)
|
||||
}
|
||||
|
||||
private val Int.localized: String
|
||||
get() = numberFormat.format(this)
|
||||
|
||||
companion object {
|
||||
const val CHINESE = "zh"
|
||||
const val INDONESIAN = "id"
|
||||
const val SIMPLIFIED_CHINESE = "zh-Hans"
|
||||
const val SPANISH = "es"
|
||||
const val FRENCH = "fr"
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
const val ENGLISH = "en"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.kanade.tachiyomi.multisrc.bilibili
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://www.bilibilicomics.com/detail/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.
|
||||
*
|
||||
* Main goal was to make it easier to open manga in Tachiyomi in spite of the DDoS blocking
|
||||
* the usual search screen from working.
|
||||
*/
|
||||
class BilibiliUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
// Mobile site of https://manga.bilibili.com starts with path "m"
|
||||
val titleId = if (pathSegments[0] == "m") pathSegments[2] else pathSegments[1]
|
||||
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", Bilibili.PREFIX_ID_SEARCH + titleId)
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("BilibiliUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("BilibiliUrlActivity", "Could not parse URI from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bilibili
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [Why are some chapters missing?](#why-are-some-chapters-missing)
|
||||
- [Guides](#Guides)
|
||||
- [Reading already paid chapters](#reading-already-paid-chapters)
|
||||
|
||||
Don't find the question you are looking for? Go check out our general FAQs and Guides
|
||||
over at [Extension FAQ] or [Getting Started].
|
||||
|
||||
[Extension FAQ]: https://tachiyomi.org/help/faq/#extensions
|
||||
[Getting Started]: https://tachiyomi.org/help/guides/getting-started/#installation
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why are some chapters missing?
|
||||
|
||||
Bilibili now have series with paid chapters. These will be filtered out from
|
||||
the chapter list by default if you didn't buy it before or if you're not signed in.
|
||||
To sign in with your existing account, follow the guide available above.
|
||||
|
||||
## Guides
|
||||
|
||||
### Reading already paid chapters
|
||||
|
||||
The **Bilibili Comics** sources allows the reading of paid chapters in your account.
|
||||
Follow the following steps to be able to sign in and get access to them:
|
||||
|
||||
1. Open the popular or latest section of the source.
|
||||
2. Open the WebView by clicking the button with a globe icon.
|
||||
3. Do the login with your existing account *(read the observations section)*.
|
||||
4. Close the WebView and refresh the chapter list of the titles
|
||||
you want to read the already paid chapters.
|
||||
|
||||
#### Observations
|
||||
|
||||
- Sign in with your Google account is not supported due to WebView restrictions
|
||||
access that Google have. **You need to have a simple account in order to be able
|
||||
to login via WebView**.
|
||||
- You may sometime face the *"Failed to refresh the token"* error. To fix it,
|
||||
you just need to open the WebView, await for the website to completely load.
|
||||
After that, you can close the WebView and try again.
|
||||
- The extension **will not** bypass any payment requirement. You still do need
|
||||
to buy the chapters you want to read or wait until they become available and
|
||||
added to your account.
|
||||
@@ -0,0 +1,122 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comicgamma
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
open class ComicGamma(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "ja",
|
||||
) : ParsedHttpSource() {
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client = network.client.newBuilder().addInterceptor(PtImgInterceptor).build()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga/", headers)
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
override fun popularMangaSelector() = ".tab_panel.active .manga_item"
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
url = element.selectFirst(Evaluator.Tag("a"))!!.attr("href")
|
||||
title = element.selectFirst(Evaluator.Class("manga_title"))!!.text()
|
||||
author = element.selectFirst(Evaluator.Class("manga_author"))!!.text()
|
||||
val genreList = element.select(Evaluator.Tag("li")).map { it.text() }
|
||||
genre = genreList.joinToString()
|
||||
status = when {
|
||||
genreList.contains("完結") && !genreList.contains("リピート配信") -> SManga.COMPLETED
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
thumbnail_url = element.selectFirst(Evaluator.Tag("img"))!!.absUrl("src")
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("Not used.")
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException("Not used.")
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException("Not used.")
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
fetchPopularManga(page).map { p -> MangasPage(p.mangas.filter { it.title.contains(query) }, false) }
|
||||
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not used.")
|
||||
override fun searchMangaSelector() = throw UnsupportedOperationException("Not used.")
|
||||
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Not used.")
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun pageListParse(document: Document) =
|
||||
document.select("#content > div[data-ptimg]").mapIndexed { i, e ->
|
||||
Page(i, imageUrl = e.attr("abs:data-ptimg"))
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val titleElement = document.selectFirst(Evaluator.Class("manga__title"))!!
|
||||
val titleName = titleElement.child(0).text()
|
||||
val desc = document.selectFirst(".detail__item > p:not(:empty)")?.run {
|
||||
select(Evaluator.Tag("br")).prepend("\\n")
|
||||
this.text().replace("\\n", "\n").replace("\n ", "\n")
|
||||
}
|
||||
val listResponse = client.newCall(popularMangaRequest(0)).execute()
|
||||
val manga = popularMangaParse(listResponse).mangas.find { it.title == titleName }
|
||||
return manga?.apply { description = desc } ?: SManga.create().apply {
|
||||
author = titleElement.child(1).text()
|
||||
description = desc
|
||||
status = SManga.UNKNOWN
|
||||
val slug = document.location().removeSuffix("/").substringAfterLast("/")
|
||||
thumbnail_url = "$baseUrl/img/manga_thumb/${slug}_list.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".read__area > .read__outer > a:not([href=#comics])"
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
url = element.attr("href").toOldChapterUrl()
|
||||
val number = url.removeSuffix("/").substringAfterLast('/').replace('_', '.')
|
||||
val list = element.selectFirst(Evaluator.Class("read__contents"))!!.children()
|
||||
name = "[$number] ${list[0].text()}"
|
||||
if (list.size >= 3) {
|
||||
date_upload = dateFormat.parseJST(list[2].text())?.time ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) =
|
||||
GET(baseUrl + chapter.url.toNewChapterUrl(), headers)
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
companion object {
|
||||
internal fun SimpleDateFormat.parseJST(date: String) = parse(date)?.apply {
|
||||
time += 12 * 3600 * 1000 // updates at 12 noon
|
||||
}
|
||||
|
||||
private fun getJSTFormat(datePattern: String) =
|
||||
SimpleDateFormat(datePattern, Locale.JAPANESE).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT+09:00")
|
||||
}
|
||||
|
||||
private val dateFormat by lazy { getJSTFormat("yyyy年M月dd日") }
|
||||
|
||||
private fun String.toOldChapterUrl(): String {
|
||||
// ../../../_files/madeinabyss/063_2/
|
||||
val segments = split('/')
|
||||
val size = segments.size
|
||||
val slug = segments[size - 3]
|
||||
val number = segments[size - 2]
|
||||
return "/manga/$slug/_files/$number/"
|
||||
}
|
||||
|
||||
private fun String.toNewChapterUrl(): String {
|
||||
val segments = split('/')
|
||||
return "/_files/${segments[2]}/${segments[4]}/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comicgamma
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class ComicGammaGenerator : ThemeSourceGenerator {
|
||||
override val themeClass = "ComicGamma"
|
||||
override val themePkg = "comicgamma"
|
||||
override val baseVersionCode = 6
|
||||
override val sources = listOf(
|
||||
SingleLang(
|
||||
name = "Web Comic Gamma",
|
||||
baseUrl = "https://webcomicgamma.takeshobo.co.jp",
|
||||
lang = "ja",
|
||||
isNsfw = false,
|
||||
className = "WebComicGamma",
|
||||
pkgName = "webcomicgamma",
|
||||
sourceName = "Web Comic Gamma",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
SingleLang(
|
||||
name = "Web Comic Gamma Plus",
|
||||
baseUrl = "https://gammaplus.takeshobo.co.jp",
|
||||
lang = "ja",
|
||||
isNsfw = true,
|
||||
className = "WebComicGammaPlus",
|
||||
pkgName = "webcomicgammaplus",
|
||||
sourceName = "Web Comic Gamma Plus",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
ComicGammaGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comicgamma
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
val COORD_REGEX = Regex("""^i:(\d+),(\d+)\+(\d+),(\d+)>(\d+),(\d+)$""")
|
||||
|
||||
@Serializable
|
||||
class PtImg(val resources: Resource, val views: List<View>) {
|
||||
fun getFilename() = resources.i.src
|
||||
fun getViewSize() = Pair(views[0].width, views[0].height)
|
||||
fun getTranslations() = views[0].coords.map { coord ->
|
||||
val v = COORD_REGEX.matchEntire(coord)!!.groupValues.drop(1).map { it.toInt() }
|
||||
Translation(v[0], v[1], v[2], v[3], v[4], v[5])
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Resource(val i: Image)
|
||||
|
||||
@Serializable
|
||||
class Image(val src: String, val width: Int, val height: Int)
|
||||
|
||||
@Serializable
|
||||
class View(val width: Int, val height: Int, val coords: List<String>)
|
||||
|
||||
class Translation(val ix: Int, val iy: Int, val w: Int, val h: Int, val vx: Int, val vy: Int)
|
||||
@@ -0,0 +1,49 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comicgamma
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object PtImgInterceptor : Interceptor {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val url = request.url
|
||||
val path = url.pathSegments
|
||||
if (!path.last().endsWith(".ptimg.json")) return response
|
||||
|
||||
val metadata = json.decodeFromString<PtImg>(response.body.string())
|
||||
val imageUrl = url.newBuilder().setEncodedPathSegment(path.size - 1, metadata.getFilename()).build()
|
||||
val imgRequest = request.newBuilder().url(imageUrl).build()
|
||||
val imgResponse = chain.proceed(imgRequest)
|
||||
val image = BitmapFactory.decodeStream(imgResponse.body.byteStream())
|
||||
val (width, height) = metadata.getViewSize()
|
||||
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val view = Canvas(result)
|
||||
|
||||
metadata.getTranslations().forEach {
|
||||
val src = Rect(it.ix, it.iy, it.ix + it.w, it.iy + it.h)
|
||||
val dst = Rect(it.vx, it.vy, it.vx + it.w, it.vy + it.h)
|
||||
view.drawBitmap(image, src, dst, null)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
val responseBody = output.toByteArray().toResponseBody(jpegMediaType)
|
||||
return imgResponse.newBuilder().body(responseBody).build()
|
||||
}
|
||||
|
||||
private val jpegMediaType = "image/jpeg".toMediaType()
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package eu.kanade.tachiyomi.multisrc.eromuse
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
@ExperimentalStdlibApi
|
||||
open class EroMuse(override val name: String, override val baseUrl: String) : HttpSource() {
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
/**
|
||||
* Browse, search, and latest all run through an ArrayDeque of requests that acts as a stack we push and pop to/from
|
||||
* For the fetch functions, we only need to worry about pushing the first page to the stack because subsequent pages
|
||||
* get pushed to the stack during parseManga(). Page 1's URL must include page=1 if the next page would be page=2,
|
||||
* if page 2 is path_to/2, nothing special needs to be done.
|
||||
*/
|
||||
|
||||
// the stack - shouldn't need to touch these except for visibility
|
||||
protected data class StackItem(val url: String, val pageType: Int)
|
||||
private lateinit var stackItem: StackItem
|
||||
protected val pageStack = ArrayDeque<StackItem>()
|
||||
companion object {
|
||||
const val VARIOUS_AUTHORS = 0
|
||||
const val AUTHOR = 1
|
||||
const val SEARCH_RESULTS_OR_BASE = 2
|
||||
}
|
||||
protected lateinit var currentSortingMode: String
|
||||
|
||||
private val albums = getAlbumList()
|
||||
|
||||
// might need to override for new sources
|
||||
private val nextPageSelector = ".pagination span.current + span a"
|
||||
protected open val albumSelector = "a.c-tile:has(img):not(:has(.members-only))"
|
||||
protected open val topLevelPathSegment = "comics/album"
|
||||
private val pageQueryRegex = Regex("""page=\d+""")
|
||||
|
||||
private fun Document.nextPageOrNull(): String? {
|
||||
val url = this.location()
|
||||
return this.select(nextPageSelector).firstOrNull()?.text()?.toIntOrNull()?.let { int ->
|
||||
if (url.contains(pageQueryRegex)) {
|
||||
url.replace(pageQueryRegex, "page=$int")
|
||||
} else {
|
||||
val httpUrl = url.toHttpUrlOrNull()!!
|
||||
val builder = if (httpUrl.pathSegments.last().toIntOrNull() is Int) {
|
||||
httpUrl.newBuilder().removePathSegment(httpUrl.pathSegments.lastIndex)
|
||||
} else {
|
||||
httpUrl.newBuilder()
|
||||
}
|
||||
builder.addPathSegment(int.toString()).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Document.addNextPageToStack() {
|
||||
this.nextPageOrNull()?.let { pageStack.add(StackItem(it, stackItem.pageType)) }
|
||||
}
|
||||
|
||||
protected fun Element.imgAttr(): String = if (this.hasAttr("data-src")) this.attr("abs:data-src") else this.attr("abs:src")
|
||||
|
||||
private fun mangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
title = element.text()
|
||||
thumbnail_url = element.select("img").firstOrNull()?.imgAttr()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun getAlbumType(url: String, default: Int = AUTHOR): Int {
|
||||
return albums.filter { it.third != SEARCH_RESULTS_OR_BASE && url.contains(it.second, true) }
|
||||
.getOrElse(0) { Triple(null, null, default) }.third
|
||||
}
|
||||
|
||||
protected fun parseManga(document: Document): MangasPage {
|
||||
fun internalParse(internalDocument: Document): List<SManga> {
|
||||
val authorDocument = if (stackItem.pageType == VARIOUS_AUTHORS) {
|
||||
internalDocument.select(albumSelector).let {
|
||||
elements ->
|
||||
elements.reversed().map { pageStack.addLast(StackItem(it.attr("abs:href"), AUTHOR)) }
|
||||
}
|
||||
client.newCall(stackRequest()).execute().asJsoup()
|
||||
} else {
|
||||
internalDocument
|
||||
}
|
||||
authorDocument.addNextPageToStack()
|
||||
return authorDocument.select(albumSelector).map { mangaFromElement(it) }
|
||||
}
|
||||
|
||||
if (stackItem.pageType in listOf(VARIOUS_AUTHORS, SEARCH_RESULTS_OR_BASE)) document.addNextPageToStack()
|
||||
val mangas = when (stackItem.pageType) {
|
||||
VARIOUS_AUTHORS -> {
|
||||
document.select(albumSelector).let {
|
||||
elements ->
|
||||
elements.reversed().map { pageStack.addLast(StackItem(it.attr("abs:href"), AUTHOR)) }
|
||||
}
|
||||
internalParse(document)
|
||||
}
|
||||
AUTHOR -> {
|
||||
internalParse(document)
|
||||
}
|
||||
SEARCH_RESULTS_OR_BASE -> {
|
||||
val searchMangas = mutableListOf<SManga>()
|
||||
document.select(albumSelector)
|
||||
.map { element ->
|
||||
val url = element.attr("abs:href")
|
||||
val depth = url.removePrefix("$baseUrl/$topLevelPathSegment/").split("/").count()
|
||||
|
||||
when (getAlbumType(url)) {
|
||||
VARIOUS_AUTHORS -> {
|
||||
when (depth) {
|
||||
1 -> { // eg. /comics/album/Fakku-Comics
|
||||
pageStack.addLast(StackItem(url, VARIOUS_AUTHORS))
|
||||
if (searchMangas.isEmpty()) searchMangas += internalParse(client.newCall(stackRequest()).execute().asJsoup()) else null
|
||||
}
|
||||
2 -> { // eg. /comics/album/Fakku-Comics/Bosshi
|
||||
pageStack.addLast(StackItem(url, AUTHOR))
|
||||
if (searchMangas.isEmpty()) searchMangas += internalParse(client.newCall(stackRequest()).execute().asJsoup()) else null
|
||||
}
|
||||
else -> {
|
||||
// eg. 3 -> /comics/album/Fakku-Comics/Bosshi/After-Summer-After
|
||||
// eg. 5 -> /comics/album/Various-Authors/Firollian/Reward/Reward-22/ElfAlfie
|
||||
// eg. 6 -> /comics/album/Various-Authors/Firollian/Area69/Area69-no_1/SamusAran/001_Dialogue
|
||||
searchMangas.add(mangaFromElement(element))
|
||||
}
|
||||
}
|
||||
}
|
||||
AUTHOR -> {
|
||||
if (depth == 1) { // eg. /comics/album/ShadBase-Comics
|
||||
pageStack.addLast(StackItem(url, AUTHOR))
|
||||
if (searchMangas.isEmpty()) searchMangas += internalParse(client.newCall(stackRequest()).execute().asJsoup()) else null
|
||||
} else {
|
||||
// eg. 2 -> /comics/album/ShadBase-Comics/RickMorty
|
||||
// eg. 3 -> /comics/album/Incase-Comics/Comic/Alfie
|
||||
searchMangas.add(mangaFromElement(element))
|
||||
}
|
||||
}
|
||||
else -> null // SEARCH_RESULTS_OR_BASE shouldn't be a case
|
||||
}
|
||||
}
|
||||
searchMangas
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
return MangasPage(mangas, pageStack.isNotEmpty())
|
||||
}
|
||||
|
||||
protected fun stackRequest(): Request {
|
||||
stackItem = pageStack.removeLast()
|
||||
val url = if (stackItem.pageType == AUTHOR && currentSortingMode.isNotEmpty() && !stackItem.url.contains("sort")) {
|
||||
stackItem.url.toHttpUrlOrNull()!!.newBuilder().addQueryParameter("sort", currentSortingMode).toString()
|
||||
} else {
|
||||
stackItem.url
|
||||
}
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
protected fun fetchManga(url: String, page: Int, sortingMode: String): Observable<MangasPage> {
|
||||
if (page == 1) {
|
||||
pageStack.clear()
|
||||
pageStack.addLast(StackItem(url, VARIOUS_AUTHORS))
|
||||
currentSortingMode = sortingMode
|
||||
}
|
||||
|
||||
return client.newCall(stackRequest())
|
||||
.asObservableSuccess()
|
||||
.map { response -> parseManga(response.asJsoup()) }
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> = fetchManga("$baseUrl/comics/album/Various-Authors", page, "")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException("Not used")
|
||||
override fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Latest
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> = fetchManga("$baseUrl/comics/album/Various-Authors?sort=date", page, "date")
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Not used")
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (page == 1) {
|
||||
pageStack.clear()
|
||||
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
currentSortingMode = filterList.filterIsInstance<SortFilter>().first().toQueryValue()
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
val url = "$baseUrl/search?q=$query".toHttpUrlOrNull()!!.newBuilder().apply {
|
||||
if (currentSortingMode.isNotEmpty()) addQueryParameter("sort", currentSortingMode)
|
||||
addQueryParameter("page", "1")
|
||||
}
|
||||
pageStack.addLast(StackItem(url.toString(), SEARCH_RESULTS_OR_BASE))
|
||||
} else {
|
||||
val albumFilter = filterList.filterIsInstance<AlbumFilter>().first().selection()
|
||||
val url = "$baseUrl/comics/${albumFilter.pathSegments}".toHttpUrlOrNull()!!.newBuilder().apply {
|
||||
if (currentSortingMode.isNotEmpty()) addQueryParameter("sort", currentSortingMode)
|
||||
if (albumFilter.pageType != AUTHOR) addQueryParameter("page", "1")
|
||||
}
|
||||
pageStack.addLast(StackItem(url.toString(), albumFilter.pageType))
|
||||
}
|
||||
}
|
||||
|
||||
return client.newCall(stackRequest())
|
||||
.asObservableSuccess()
|
||||
.map { response -> parseManga(response.asJsoup()) }
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException("Not used")
|
||||
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return SManga.create().apply {
|
||||
with(response.asJsoup()) {
|
||||
setUrlWithoutDomain(response.request.url.toString())
|
||||
thumbnail_url = select("$albumSelector img").firstOrNull()?.imgAttr()
|
||||
author = when (getAlbumType(url)) {
|
||||
AUTHOR -> {
|
||||
// eg. https://comics.8muses.com/comics/album/ShadBase-Comics/RickMorty
|
||||
// eg. https://comics.8muses.com/comics/album/Incase-Comics/Comic/Alfie
|
||||
select("div.top-menu-breadcrumb li:nth-child(2)").text()
|
||||
}
|
||||
VARIOUS_AUTHORS -> {
|
||||
// eg. https://comics.8muses.com/comics/album/Various-Authors/NLT-Media/A-Sunday-Schooling
|
||||
select("div.top-menu-breadcrumb li:nth-child(3)").text()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
protected open val linkedChapterSelector = "a.c-tile:has(img)[href*=/comics/album/]"
|
||||
protected open val pageThumbnailSelector = "a.c-tile:has(img)[href*=/comics/picture/] img"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
fun parseChapters(document: Document, isFirstPage: Boolean, chapters: ArrayDeque<SChapter>): List<SChapter> {
|
||||
// Linked chapters
|
||||
document.select(linkedChapterSelector)
|
||||
.mapNotNull {
|
||||
chapters.addFirst(
|
||||
SChapter.create().apply {
|
||||
name = it.text()
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isFirstPage) {
|
||||
// Self
|
||||
document.select(pageThumbnailSelector).firstOrNull()?.let {
|
||||
chapters.add(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
setUrlWithoutDomain(response.request.url.toString())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
document.nextPageOrNull()?.let { url -> parseChapters(client.newCall(GET(url, headers)).execute().asJsoup(), false, chapters) }
|
||||
return chapters
|
||||
}
|
||||
|
||||
return parseChapters(response.asJsoup(), true, ArrayDeque())
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
protected open val pageThumbnailPathSegment = "/th/"
|
||||
protected open val pageFullSizePathSegment = "/fl/"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
fun parsePages(
|
||||
document: Document,
|
||||
nestedChapterDocuments: ArrayDeque<Document> = ArrayDeque(),
|
||||
pages: ArrayList<Page> = ArrayList(),
|
||||
): List<Page> {
|
||||
// Nested chapters aka folders
|
||||
document.select(linkedChapterSelector)
|
||||
.mapNotNull {
|
||||
nestedChapterDocuments.add(
|
||||
client.newCall(GET(it.attr("abs:href"), headers)).execute().asJsoup(),
|
||||
)
|
||||
}
|
||||
|
||||
var lastPage: Int = pages.size
|
||||
pages.addAll(
|
||||
document.select(pageThumbnailSelector).mapIndexed { i, img ->
|
||||
Page(lastPage + i, "", img.imgAttr().replace(pageThumbnailPathSegment, pageFullSizePathSegment))
|
||||
},
|
||||
)
|
||||
|
||||
document.nextPageOrNull()?.let {
|
||||
url ->
|
||||
pages.addAll(parsePages(client.newCall(GET(url, headers)).execute().asJsoup(), nestedChapterDocuments, pages))
|
||||
}
|
||||
|
||||
while (!nestedChapterDocuments.isEmpty()) {
|
||||
pages.addAll(parsePages(nestedChapterDocuments.removeFirst()))
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
return parsePages(response.asJsoup())
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header("Text search only combines with sort!"),
|
||||
Filter.Separator(),
|
||||
AlbumFilter(getAlbumList()),
|
||||
SortFilter(getSortList()),
|
||||
)
|
||||
}
|
||||
|
||||
protected class AlbumFilter(private val vals: Array<Triple<String, String, Int>>) : Filter.Select<String>("Album", vals.map { it.first }.toTypedArray()) {
|
||||
fun selection() = AlbumFilterData(vals[state].second, vals[state].third)
|
||||
data class AlbumFilterData(val pathSegments: String, val pageType: Int)
|
||||
}
|
||||
protected open fun getAlbumList() = arrayOf(
|
||||
Triple("All Authors", "", SEARCH_RESULTS_OR_BASE),
|
||||
Triple("Various Authors", "album/Various-Authors", VARIOUS_AUTHORS),
|
||||
Triple("Fakku Comics", "album/Fakku-Comics", VARIOUS_AUTHORS),
|
||||
Triple("Hentai and Manga English", "album/Hentai-and-Manga-English", VARIOUS_AUTHORS),
|
||||
Triple("Fake Celebrities Sex Pictures", "album/Fake-Celebrities-Sex-Pictures", AUTHOR),
|
||||
Triple("MilfToon Comics", "album/MilfToon-Comics", AUTHOR),
|
||||
Triple("BE Story Club Comics", "album/BE-Story-Club-Comics", AUTHOR),
|
||||
Triple("ShadBase Comics", "album/ShadBase-Comics", AUTHOR),
|
||||
Triple("ZZZ Comics", "album/ZZZ-Comics", AUTHOR),
|
||||
Triple("PalComix Comics", "album/PalComix-Comics", AUTHOR),
|
||||
Triple("MCC Comics", "album/MCC-Comics", AUTHOR),
|
||||
Triple("Expansionfan Comics", "album/Expansionfan-Comics", AUTHOR),
|
||||
Triple("JAB Comics", "album/JAB-Comics", AUTHOR),
|
||||
Triple("Giantess Fan Comics", "album/Giantess-Fan-Comics", AUTHOR),
|
||||
Triple("Renderotica Comics", "album/Renderotica-Comics", AUTHOR),
|
||||
Triple("IllustratedInterracial.com Comics", "album/IllustratedInterracial_com-Comics", AUTHOR),
|
||||
Triple("Giantess Club Comics", "album/Giantess-Club-Comics", AUTHOR),
|
||||
Triple("Innocent Dickgirls Comics", "album/Innocent-Dickgirls-Comics", AUTHOR),
|
||||
Triple("Locofuria Comics", "album/Locofuria-Comics", AUTHOR),
|
||||
Triple("PigKing - CrazyDad Comics", "album/PigKing-CrazyDad-Comics", AUTHOR),
|
||||
Triple("Cartoon Reality Comics", "album/Cartoon-Reality-Comics", AUTHOR),
|
||||
Triple("Affect3D Comics", "album/Affect3D-Comics", AUTHOR),
|
||||
Triple("TG Comics", "album/TG-Comics", AUTHOR),
|
||||
Triple("Melkormancin.com Comics", "album/Melkormancin_com-Comics", AUTHOR),
|
||||
Triple("Seiren.com.br Comics", "album/Seiren_com_br-Comics", AUTHOR),
|
||||
Triple("Tracy Scops Comics", "album/Tracy-Scops-Comics", AUTHOR),
|
||||
Triple("Fred Perry Comics", "album/Fred-Perry-Comics", AUTHOR),
|
||||
Triple("Witchking00 Comics", "album/Witchking00-Comics", AUTHOR),
|
||||
Triple("8muses Comics", "album/8muses-Comics", AUTHOR),
|
||||
Triple("KAOS Comics", "album/KAOS-Comics", AUTHOR),
|
||||
Triple("Vaesark Comics", "album/Vaesark-Comics", AUTHOR),
|
||||
Triple("Fansadox Comics", "album/Fansadox-Comics", AUTHOR),
|
||||
Triple("DreamTales Comics", "album/DreamTales-Comics", AUTHOR),
|
||||
Triple("Croc Comics", "album/Croc-Comics", AUTHOR),
|
||||
Triple("Jay Marvel Comics", "album/Jay-Marvel-Comics", AUTHOR),
|
||||
Triple("JohnPersons.com Comics", "album/JohnPersons_com-Comics", AUTHOR),
|
||||
Triple("MuscleFan Comics", "album/MuscleFan-Comics", AUTHOR),
|
||||
Triple("Taboolicious.xxx Comics", "album/Taboolicious_xxx-Comics", AUTHOR),
|
||||
Triple("MongoBongo Comics", "album/MongoBongo-Comics", AUTHOR),
|
||||
Triple("Slipshine Comics", "album/Slipshine-Comics", AUTHOR),
|
||||
Triple("Everfire Comics", "album/Everfire-Comics", AUTHOR),
|
||||
Triple("PrismGirls Comics", "album/PrismGirls-Comics", AUTHOR),
|
||||
Triple("Abimboleb Comics", "album/Abimboleb-Comics", AUTHOR),
|
||||
Triple("Y3DF - Your3DFantasy.com Comics", "album/Y3DF-Your3DFantasy_com-Comics", AUTHOR),
|
||||
Triple("Grow Comics", "album/Grow-Comics", AUTHOR),
|
||||
Triple("OkayOkayOKOk Comics", "album/OkayOkayOKOk-Comics", AUTHOR),
|
||||
Triple("Tufos Comics", "album/Tufos-Comics", AUTHOR),
|
||||
Triple("Cartoon Valley", "album/Cartoon-Valley", AUTHOR),
|
||||
Triple("3DMonsterStories.com Comics", "album/3DMonsterStories_com-Comics", AUTHOR),
|
||||
Triple("Kogeikun Comics", "album/Kogeikun-Comics", AUTHOR),
|
||||
Triple("The Foxxx Comics", "album/The-Foxxx-Comics", AUTHOR),
|
||||
Triple("Theme Collections", "album/Theme-Collections", AUTHOR),
|
||||
Triple("Interracial-Comics", "album/Interracial-Comics", AUTHOR),
|
||||
Triple("Expansion Comics", "album/Expansion-Comics", AUTHOR),
|
||||
Triple("Moiarte Comics", "album/Moiarte-Comics", AUTHOR),
|
||||
Triple("Incognitymous Comics", "album/Incognitymous-Comics", AUTHOR),
|
||||
Triple("DizzyDills Comics", "album/DizzyDills-Comics", AUTHOR),
|
||||
Triple("DukesHardcoreHoneys.com Comics", "album/DukesHardcoreHoneys_com-Comics", AUTHOR),
|
||||
Triple("Stormfeder Comics", "album/Stormfeder-Comics", AUTHOR),
|
||||
Triple("Bimbo Story Club Comics", "album/Bimbo-Story-Club-Comics", AUTHOR),
|
||||
Triple("Smudge Comics", "album/Smudge-Comics", AUTHOR),
|
||||
Triple("Dollproject Comics", "album/Dollproject-Comics", AUTHOR),
|
||||
Triple("SuperHeroineComixxx", "album/SuperHeroineComixxx", AUTHOR),
|
||||
Triple("Karmagik Comics", "album/Karmagik-Comics", AUTHOR),
|
||||
Triple("Blacknwhite.com Comics", "album/Blacknwhite_com-Comics", AUTHOR),
|
||||
Triple("ArtOfJaguar Comics", "album/ArtOfJaguar-Comics", AUTHOR),
|
||||
Triple("Kirtu.com Comics", "album/Kirtu_com-Comics", AUTHOR),
|
||||
Triple("UberMonkey Comics", "album/UberMonkey-Comics", AUTHOR),
|
||||
Triple("DarkSoul3D Comics", "album/DarkSoul3D-Comics", AUTHOR),
|
||||
Triple("Markydaysaid Comics", "album/Markydaysaid-Comics", AUTHOR),
|
||||
Triple("Central Comics", "album/Central-Comics", AUTHOR),
|
||||
Triple("Frozen Parody Comics", "album/Frozen-Parody-Comics", AUTHOR),
|
||||
Triple("Blacknwhitecomics.com Comix", "album/Blacknwhitecomics_com-Comix", AUTHOR),
|
||||
)
|
||||
|
||||
protected class SortFilter(private val vals: Array<Pair<String, String>>) : Filter.Select<String>("Sort Order", vals.map { it.first }.toTypedArray()) {
|
||||
fun toQueryValue() = vals[state].second
|
||||
}
|
||||
protected open fun getSortList() = arrayOf(
|
||||
Pair("Views", ""),
|
||||
Pair("Likes", "like"),
|
||||
Pair("Date", "date"),
|
||||
Pair("A-Z", "az"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.multisrc.eromuse
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class EroMuseGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "eromuse"
|
||||
|
||||
override val themeClass = "EroMuse"
|
||||
|
||||
override val baseVersionCode: Int = 1
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("8Muses", "https://comics.8muses.com", "en", className = "EightMuses", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Erofus", "https://www.erofus.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
EroMuseGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package eu.kanade.tachiyomi.multisrc.fansubscat
|
||||
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
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.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class FansubsCat(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
val isHentaiSite: Boolean,
|
||||
) : HttpSource() {
|
||||
|
||||
private val apiBaseUrl = "https://api.fansubs.cat"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("User-Agent", "Tachiyomi/${AppInfo.getVersionName()}")
|
||||
|
||||
override val client: OkHttpClient = network.client
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private fun parseMangaFromJson(response: Response): MangasPage {
|
||||
val jsonObject = json.decodeFromString<JsonObject>(response.body.string())
|
||||
|
||||
val mangas = jsonObject["result"]!!.jsonArray.map { json ->
|
||||
SManga.create().apply {
|
||||
url = json.jsonObject["slug"]!!.jsonPrimitive.content
|
||||
title = json.jsonObject["name"]!!.jsonPrimitive.content
|
||||
thumbnail_url = json.jsonObject["thumbnail_url"]!!.jsonPrimitive.content
|
||||
author = json.jsonObject["author"]!!.jsonPrimitive.contentOrNull
|
||||
description = json.jsonObject["synopsis"]!!.jsonPrimitive.contentOrNull
|
||||
status = json.jsonObject["status"]!!.jsonPrimitive.content.toStatus()
|
||||
genre = json.jsonObject["genres"]!!.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangas, mangas.size >= 20)
|
||||
}
|
||||
|
||||
private fun parseChapterListFromJson(response: Response): List<SChapter> {
|
||||
val jsonObject = json.decodeFromString<JsonObject>(response.body.string())
|
||||
|
||||
return jsonObject["result"]!!.jsonArray.map { json ->
|
||||
SChapter.create().apply {
|
||||
url = json.jsonObject["id"]!!.jsonPrimitive.content
|
||||
name = json.jsonObject["title"]!!.jsonPrimitive.content
|
||||
chapter_number = json.jsonObject["number"]!!.jsonPrimitive.float
|
||||
scanlator = json.jsonObject["fansub"]!!.jsonPrimitive.content
|
||||
date_upload = json.jsonObject["created"]!!.jsonPrimitive.long
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePageListFromJson(response: Response): List<Page> {
|
||||
val jsonObject = json.decodeFromString<JsonObject>(response.body.string())
|
||||
|
||||
return jsonObject["result"]!!.jsonArray.mapIndexed { i, it ->
|
||||
Page(
|
||||
i,
|
||||
it.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
it.jsonObject["url"]!!.jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiBaseUrl/manga/popular/$page?hentai=$isHentaiSite", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = parseMangaFromJson(response)
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiBaseUrl/manga/recent/$page?hentai=$isHentaiSite", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = parseMangaFromJson(response)
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val mangaTypeFilter = filterList.find { it is MangaTypeFilter } as MangaTypeFilter
|
||||
val stateFilter = filterList.find { it is StateFilter } as StateFilter
|
||||
val demographyFilter = filterList.find { it is DemographyFilter } as DemographyFilter
|
||||
val genreFilter = filterList.find { it is GenreTagFilter } as GenreTagFilter
|
||||
val themeFilter = filterList.find { it is ThemeTagFilter } as ThemeTagFilter
|
||||
val builder = "$apiBaseUrl/manga/search/$page?hentai=$isHentaiSite".toHttpUrl().newBuilder()
|
||||
mangaTypeFilter.addQueryParameter(builder)
|
||||
stateFilter.addQueryParameter(builder)
|
||||
demographyFilter.addQueryParameter(builder)
|
||||
genreFilter.addQueryParameter(builder)
|
||||
themeFilter.addQueryParameter(builder)
|
||||
if (query.isNotBlank()) {
|
||||
builder.addQueryParameter("query", query)
|
||||
}
|
||||
return GET(builder.toString(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = parseMangaFromJson(response)
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/details/${manga.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl/${manga.url}"
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val jsonObject = json.decodeFromString<JsonObject>(response.body.string())
|
||||
val resultObject = jsonObject.jsonObject["result"]!!.jsonObject
|
||||
|
||||
return SManga.create().apply {
|
||||
url = resultObject["slug"]!!.jsonPrimitive.content
|
||||
title = resultObject["name"]!!.jsonPrimitive.content
|
||||
thumbnail_url = resultObject["thumbnail_url"]!!.jsonPrimitive.content
|
||||
author = resultObject["author"]!!.jsonPrimitive.contentOrNull
|
||||
description = resultObject["synopsis"]!!.jsonPrimitive.contentOrNull
|
||||
status = resultObject["status"]!!.jsonPrimitive.content.toStatus()
|
||||
genre = resultObject["genres"]!!.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.toStatus() = when {
|
||||
this == null -> SManga.UNKNOWN
|
||||
this.contains("ongoing", ignoreCase = true) -> SManga.ONGOING
|
||||
this.contains("finished", ignoreCase = true) -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/chapters/${manga.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> =
|
||||
parseChapterListFromJson(response)
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(
|
||||
"$apiBaseUrl/manga/pages/${chapter.url.substringAfterLast('/')}?hentai=$isHentaiSite",
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
return "$baseUrl/${chapter.url.replace("/", "?f=")}"
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> = parsePageListFromJson(response)
|
||||
|
||||
override fun imageUrlParse(response: Response): String =
|
||||
throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Filter
|
||||
override fun getFilterList() = FilterList(
|
||||
listOfNotNull(
|
||||
MangaTypeFilter("Tipus", getMangaTypeList()),
|
||||
StateFilter("Estat", getStateList()),
|
||||
if (!isHentaiSite) {
|
||||
DemographyFilter("Demografies", getDemographyList())
|
||||
} else {
|
||||
null
|
||||
},
|
||||
GenreTagFilter("Gèneres (inclou/exclou)", getGenreList()),
|
||||
ThemeTagFilter("Temàtiques (inclou/exclou)", getThemeList()),
|
||||
),
|
||||
)
|
||||
|
||||
private fun getMangaTypeList() = listOf(
|
||||
MangaType("oneshot", "One-shots"),
|
||||
MangaType("serialized", "Serialitzats"),
|
||||
)
|
||||
|
||||
private fun getStateList() = listOf(
|
||||
State(1, "Completat"),
|
||||
State(2, "En procés"),
|
||||
State(3, "Parcialment completat"),
|
||||
State(4, "Abandonat"),
|
||||
State(5, "Cancel·lat"),
|
||||
)
|
||||
|
||||
private fun getDemographyList() = listOf(
|
||||
Demography(35, "Infantil"),
|
||||
Demography(27, "Josei"),
|
||||
Demography(12, "Seinen"),
|
||||
Demography(16, "Shōjo"),
|
||||
Demography(1, "Shōnen"),
|
||||
Demography(-1, "No definida"),
|
||||
)
|
||||
|
||||
private fun getGenreList() = listOfNotNull(
|
||||
Tag(4, "Acció"),
|
||||
Tag(7, "Amor"),
|
||||
Tag(38, "Amor entre noies"),
|
||||
Tag(23, "Amor entre nois"),
|
||||
Tag(31, "Avantguardisme"),
|
||||
Tag(6, "Aventura"),
|
||||
Tag(10, "Ciència-ficció"),
|
||||
Tag(2, "Comèdia"),
|
||||
Tag(47, "De prestigi"),
|
||||
Tag(3, "Drama"),
|
||||
Tag(19, "Ecchi"),
|
||||
Tag(46, "Erotisme"),
|
||||
Tag(20, "Esports"),
|
||||
Tag(5, "Fantasia"),
|
||||
Tag(48, "Gastronomia"),
|
||||
if (isHentaiSite) {
|
||||
Tag(34, "Hentai")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
Tag(11, "Misteri"),
|
||||
Tag(8, "Sobrenatural"),
|
||||
Tag(17, "Suspens"),
|
||||
Tag(21, "Terror"),
|
||||
Tag(42, "Vida quotidiana"),
|
||||
)
|
||||
|
||||
private fun getThemeList() = listOf(
|
||||
Tag(71, "Animals de companyia"),
|
||||
Tag(50, "Antropomorfisme"),
|
||||
Tag(70, "Arts escèniques"),
|
||||
Tag(18, "Arts marcials"),
|
||||
Tag(81, "Arts visuals"),
|
||||
Tag(64, "Canvi de gènere màgic"),
|
||||
Tag(56, "Comèdia de gags"),
|
||||
Tag(68, "Crim organitzat"),
|
||||
Tag(69, "Cultura otaku"),
|
||||
Tag(30, "Curses"),
|
||||
Tag(54, "Delinqüència"),
|
||||
Tag(43, "Detectivesc"),
|
||||
Tag(55, "Educatiu"),
|
||||
Tag(9, "Escolar"),
|
||||
Tag(39, "Espai"),
|
||||
Tag(77, "Esports d’equip"),
|
||||
Tag(53, "Esports de combat"),
|
||||
Tag(25, "Harem"),
|
||||
Tag(73, "Harem invers"),
|
||||
Tag(15, "Històric"),
|
||||
Tag(59, "Idols femenines"),
|
||||
Tag(60, "Idols masculins"),
|
||||
Tag(75, "Indústria de l’entreteniment"),
|
||||
Tag(61, "Isekai"),
|
||||
Tag(58, "Joc d’alt risc"),
|
||||
Tag(33, "Joc d’estratègia"),
|
||||
Tag(82, "Laboral"),
|
||||
Tag(29, "Mecha"),
|
||||
Tag(66, "Medicina"),
|
||||
Tag(67, "Memòries"),
|
||||
Tag(22, "Militar"),
|
||||
Tag(32, "Mitologia"),
|
||||
Tag(26, "Música"),
|
||||
Tag(65, "Noies màgiques"),
|
||||
Tag(36, "Paròdia"),
|
||||
Tag(49, "Personatges adults"),
|
||||
Tag(51, "Personatges bufons"),
|
||||
Tag(63, "Polígon amorós"),
|
||||
Tag(13, "Psicològic"),
|
||||
Tag(52, "Puericultura"),
|
||||
Tag(72, "Reencarnació"),
|
||||
Tag(62, "Relaxant"),
|
||||
Tag(74, "Rerefons romàntic"),
|
||||
Tag(37, "Samurais"),
|
||||
Tag(57, "Sang i fetge"),
|
||||
Tag(40, "Superpoders"),
|
||||
Tag(76, "Supervivència"),
|
||||
Tag(80, "Tirana"),
|
||||
Tag(45, "Transformisme"),
|
||||
Tag(41, "Vampirs"),
|
||||
Tag(78, "Viatges en el temps"),
|
||||
Tag(79, "Videojocs"),
|
||||
)
|
||||
|
||||
private interface UrlQueryFilter {
|
||||
fun addQueryParameter(url: HttpUrl.Builder)
|
||||
}
|
||||
|
||||
internal class MangaType(val id: String, name: String) : Filter.CheckBox(name)
|
||||
internal class State(val id: Int, name: String) : Filter.CheckBox(name)
|
||||
internal class Tag(val id: Int, name: String) : Filter.TriState(name)
|
||||
internal class Demography(val id: Int, name: String) : Filter.CheckBox(name)
|
||||
|
||||
private class MangaTypeFilter(collection: String, mangaTypes: List<MangaType>) :
|
||||
Filter.Group<MangaType>(collection, mangaTypes),
|
||||
UrlQueryFilter {
|
||||
|
||||
override fun addQueryParameter(url: HttpUrl.Builder) {
|
||||
var oneShotSelected = false
|
||||
var serializedSelected = false
|
||||
state.forEach { mangaType ->
|
||||
if (mangaType.id.equals("oneshot") && mangaType.state) {
|
||||
oneShotSelected = true
|
||||
} else if (mangaType.id.equals("serialized") && mangaType.state) {
|
||||
serializedSelected = true
|
||||
}
|
||||
}
|
||||
if (oneShotSelected && !serializedSelected) {
|
||||
url.addQueryParameter("type", "oneshot")
|
||||
} else if (!oneShotSelected && serializedSelected) {
|
||||
url.addQueryParameter("type", "serialized")
|
||||
} else {
|
||||
url.addQueryParameter("type", "all")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class StateFilter(collection: String, states: List<State>) :
|
||||
Filter.Group<State>(collection, states),
|
||||
UrlQueryFilter {
|
||||
|
||||
override fun addQueryParameter(url: HttpUrl.Builder) {
|
||||
state.forEach { state ->
|
||||
if (state.state) {
|
||||
url.addQueryParameter("status[]", state.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DemographyFilter(collection: String, demographies: List<Demography>) :
|
||||
Filter.Group<Demography>(collection, demographies),
|
||||
UrlQueryFilter {
|
||||
|
||||
override fun addQueryParameter(url: HttpUrl.Builder) {
|
||||
state.forEach { demography ->
|
||||
if (demography.state) {
|
||||
url.addQueryParameter("demographies[]", demography.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GenreTagFilter(collection: String, tags: List<Tag>) :
|
||||
Filter.Group<Tag>(collection, tags),
|
||||
UrlQueryFilter {
|
||||
|
||||
override fun addQueryParameter(url: HttpUrl.Builder) {
|
||||
state.forEach { tag ->
|
||||
if (tag.isIncluded()) {
|
||||
url.addQueryParameter("genres_include[]", tag.id.toString())
|
||||
} else if (tag.isExcluded()) {
|
||||
url.addQueryParameter("genres_exclude[]", tag.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ThemeTagFilter(collection: String, tags: List<Tag>) :
|
||||
Filter.Group<Tag>(collection, tags),
|
||||
UrlQueryFilter {
|
||||
|
||||
override fun addQueryParameter(url: HttpUrl.Builder) {
|
||||
state.forEach { tag ->
|
||||
if (tag.isIncluded()) {
|
||||
url.addQueryParameter("themes_include[]", tag.id.toString())
|
||||
} else if (tag.isExcluded()) {
|
||||
url.addQueryParameter("themes_exclude[]", tag.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.multisrc.fansubscat
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class FansubsCatGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "fansubscat"
|
||||
|
||||
override val themeClass = "FansubsCat"
|
||||
|
||||
override val baseVersionCode = 4
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang(
|
||||
name = "Fansubs.cat",
|
||||
baseUrl = "https://manga.fansubs.cat",
|
||||
lang = "ca",
|
||||
className = "FansubsCatMain",
|
||||
isNsfw = false,
|
||||
pkgName = "fansubscat",
|
||||
),
|
||||
SingleLang(
|
||||
name = "Fansubs.cat - Hentai",
|
||||
baseUrl = "https://hentai.fansubs.cat/manga",
|
||||
lang = "ca",
|
||||
className = "FansubsCatHentai",
|
||||
isNsfw = true,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) = FansubsCatGenerator().createAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class FlixScans(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
protected val apiUrl: String = baseUrl.replace("://", "://api.").plus("/api/v1"),
|
||||
protected val cdnUrl: String = baseUrl.replace("://", "://api.").plus("/storage/"),
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
protected open val json: Json by injectLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", baseUrl)
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
runCatching { fetchGenre() }
|
||||
|
||||
return super.fetchPopularManga(page)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiUrl/webtoon/homepage/home", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<HomeDto>()
|
||||
|
||||
val entries = (result.hot + result.topAll + result.topMonth + result.topWeek)
|
||||
.distinctBy { it.id }
|
||||
.map { it.toSManga(cdnUrl) }
|
||||
|
||||
return MangasPage(entries, false)
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
runCatching { fetchGenre() }
|
||||
|
||||
return super.fetchLatestUpdates(page)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiUrl/search/advance?page=$page&serie_type=webtoon", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<ApiResponse<BrowseSeries>>()
|
||||
val currentPage = response.request.url.queryParameter("page")
|
||||
?.toIntOrNull() ?: 1
|
||||
|
||||
val entries = result.data.map { it.toSManga(cdnUrl) }
|
||||
val hasNextPage = result.meta.lastPage > currentPage
|
||||
|
||||
return MangasPage(entries, hasNextPage)
|
||||
}
|
||||
|
||||
private var fetchGenreList: List<GenreHolder> = emptyList()
|
||||
private var fetchGenreCallOngoing = false
|
||||
private var fetchGenreFailed = false
|
||||
private var fetchGenreAttempt = 0
|
||||
|
||||
private fun fetchGenre() {
|
||||
if (fetchGenreAttempt < 3 && (fetchGenreList.isEmpty() || fetchGenreFailed) && !fetchGenreCallOngoing) {
|
||||
fetchGenreCallOngoing = true
|
||||
|
||||
// fetch genre asynchronously as it sometimes hangs
|
||||
client.newCall(fetchGenreRequest()).enqueue(fetchGenreCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private val fetchGenreCallback = object : Callback {
|
||||
override fun onFailure(call: Call, e: okio.IOException) {
|
||||
fetchGenreAttempt++
|
||||
fetchGenreFailed = true
|
||||
fetchGenreCallOngoing = false
|
||||
|
||||
e.message?.let { Log.e("$name Filters", it) }
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
fetchGenreCallOngoing = false
|
||||
fetchGenreAttempt++
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
fetchGenreFailed = true
|
||||
response.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val parsed = runCatching {
|
||||
response.use(::fetchGenreParse)
|
||||
}
|
||||
|
||||
fetchGenreFailed = parsed.isFailure
|
||||
fetchGenreList = parsed.getOrElse {
|
||||
Log.e("$name Filters", it.stackTraceToString())
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchGenreRequest(): Request {
|
||||
return GET("$apiUrl/search/genres", headers)
|
||||
}
|
||||
|
||||
private fun fetchGenreParse(response: Response): List<GenreHolder> {
|
||||
return response.parseAs<List<GenreHolder>>()
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
Filter.Header("Ignored when using Text Search"),
|
||||
MainGenreFilter(),
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
filters += if (fetchGenreList.isNotEmpty()) {
|
||||
listOf(
|
||||
GenreFilter("Genre", fetchGenreList),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'reset' to attempt to show Genres"),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
runCatching { fetchGenre() }
|
||||
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotEmpty()) {
|
||||
val requestBody = SearchInput(query.trim())
|
||||
.let(json::encodeToString)
|
||||
.toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.add("Content-Length", requestBody.contentLength().toString())
|
||||
.add("Content-Type", requestBody.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/search/serie?page=$page", newHeaders, requestBody)
|
||||
}
|
||||
|
||||
val advSearchUrl = apiUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("search/advance")
|
||||
addQueryParameter("page", page.toString())
|
||||
addQueryParameter("serie_type", "webtoon")
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
filter.checked.let {
|
||||
if (it.isNotEmpty()) {
|
||||
addQueryParameter("genres", it.joinToString(","))
|
||||
}
|
||||
}
|
||||
}
|
||||
is MainGenreFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("main_genres", filter.selected)
|
||||
}
|
||||
}
|
||||
is TypeFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("type", filter.selected)
|
||||
}
|
||||
}
|
||||
is StatusFilter -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter("status", filter.selected)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(advSearchUrl, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val id = manga.url.split("-")[1]
|
||||
|
||||
return GET("$apiUrl/webtoon/series/$id", headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val result = response.parseAs<SeriesResponse>()
|
||||
|
||||
return result.serie.toSManga(cdnUrl)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = manga.url.split("-")[1]
|
||||
|
||||
return GET("$apiUrl/webtoon/chapters/$id-desc", headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val chapters = response.parseAs<List<Chapter>>()
|
||||
|
||||
return chapters.map(Chapter::toSChapter)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val id = chapter.url
|
||||
.substringAfterLast("/")
|
||||
.substringBefore("-")
|
||||
|
||||
return GET("$apiUrl/webtoon/chapters/chapter/$id", headers)
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<PageListResponse>()
|
||||
|
||||
return result.chapter.chapterData.webtoon.mapIndexed { i, img ->
|
||||
Page(i, "", cdnUrl + img)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used")
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): T =
|
||||
use { body.string() }.let(json::decodeFromString)
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val data: List<T>,
|
||||
val meta: PageInfo,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageInfo(
|
||||
@SerialName("last_page") val lastPage: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HomeDto(
|
||||
val hot: List<BrowseSeries>,
|
||||
val topWeek: List<BrowseSeries>,
|
||||
val topMonth: List<BrowseSeries>,
|
||||
val topAll: List<BrowseSeries>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BrowseSeries(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val prefix: Int,
|
||||
val thumbnail: String?,
|
||||
) {
|
||||
fun toSManga(cdnUrl: String) = SManga.create().apply {
|
||||
title = this@BrowseSeries.title
|
||||
url = "/series/$prefix-$id-$slug"
|
||||
thumbnail_url = thumbnail?.let { cdnUrl + it }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SearchInput(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenreHolder(
|
||||
val name: String,
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriesResponse(
|
||||
val serie: Series,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Series(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val prefix: Int,
|
||||
val thumbnail: String?,
|
||||
val story: String?,
|
||||
val serieType: String?,
|
||||
val mainGenres: String?,
|
||||
val otherNames: List<String>? = emptyList(),
|
||||
val status: String?,
|
||||
val type: String?,
|
||||
val authors: List<GenreHolder>? = emptyList(),
|
||||
val artists: List<GenreHolder>? = emptyList(),
|
||||
val genres: List<GenreHolder>? = emptyList(),
|
||||
) {
|
||||
fun toSManga(cdnUrl: String) = SManga.create().apply {
|
||||
title = this@Series.title
|
||||
url = "/series/$prefix-$id-$slug"
|
||||
thumbnail_url = cdnUrl + thumbnail
|
||||
author = authors?.joinToString { it.name.trim() }
|
||||
artist = artists?.joinToString { it.name.trim() }
|
||||
genre = (otherGenres + genres?.map { it.name.trim() }.orEmpty())
|
||||
.distinct().joinToString { it.trim() }
|
||||
description = story?.let { Jsoup.parse(it).text() }
|
||||
if (otherNames?.isNotEmpty() == true) {
|
||||
if (description.isNullOrEmpty()) {
|
||||
description = "Alternative Names:\n"
|
||||
} else {
|
||||
description += "\n\nAlternative Names:\n"
|
||||
}
|
||||
description += otherNames.joinToString("\n") { "• ${it.trim()}" }
|
||||
}
|
||||
status = when (this@Series.status?.trim()) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
"onhold" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private val otherGenres = listOfNotNull(serieType, mainGenres, type)
|
||||
.map { word ->
|
||||
word.trim().replaceFirstChar {
|
||||
if (it.isLowerCase()) {
|
||||
it.titlecase(Locale.getDefault())
|
||||
} else {
|
||||
it.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val createdAt: String? = null,
|
||||
) {
|
||||
fun toSChapter() = SChapter.create().apply {
|
||||
url = "/read/webtoon/$id-$slug"
|
||||
name = this@Chapter.name
|
||||
date_upload = runCatching { dateFormat.parse(createdAt!!)!!.time }.getOrDefault(0L)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PageListResponse(
|
||||
val chapter: ChapterPages,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPages(
|
||||
val chapterData: ChapterPageData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPageData(
|
||||
val webtoon: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class FlixScansGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "flixscans"
|
||||
|
||||
override val themeClass = "FlixScans"
|
||||
|
||||
override val baseVersionCode: Int = 3
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Flix Scans", "https://flixscans.org", "en", className = "FlixScansNet", pkgName = "flixscans"),
|
||||
SingleLang("جالاكسي مانجا", "https://flixscans.com", "ar", className = "GalaxyManga", overrideVersionCode = 26),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
FlixScansGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package eu.kanade.tachiyomi.multisrc.flixscans
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
abstract class SelectFilter(
|
||||
name: String,
|
||||
private val options: List<String>,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
options.toTypedArray(),
|
||||
) {
|
||||
val selected get() = options[state]
|
||||
}
|
||||
|
||||
class CheckBoxFilter(
|
||||
name: String,
|
||||
val id: String,
|
||||
) : Filter.CheckBox(name)
|
||||
|
||||
class GenreFilter(
|
||||
name: String,
|
||||
private val genres: List<GenreHolder>,
|
||||
) : Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
genres.map { CheckBoxFilter(it.name.trim(), it.id.toString()) },
|
||||
) {
|
||||
val checked get() = state.filter { it.state }.map { it.id }
|
||||
}
|
||||
|
||||
class MainGenreFilter : SelectFilter(
|
||||
"Main Genre",
|
||||
listOf(
|
||||
"",
|
||||
"fantasy",
|
||||
"romance",
|
||||
"action",
|
||||
"drama",
|
||||
),
|
||||
)
|
||||
|
||||
class TypeFilter : SelectFilter(
|
||||
"Type",
|
||||
listOf(
|
||||
"",
|
||||
"manhwa",
|
||||
"manhua",
|
||||
"manga",
|
||||
"comic",
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : SelectFilter(
|
||||
"Status",
|
||||
listOf(
|
||||
"",
|
||||
"ongoing",
|
||||
"completed",
|
||||
"droped",
|
||||
"onhold",
|
||||
"soon",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,493 @@
|
||||
package eu.kanade.tachiyomi.multisrc.fmreader
|
||||
|
||||
import android.util.Base64
|
||||
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.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import java.nio.charset.Charset
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* For sites based on the Flat-Manga CMS
|
||||
*/
|
||||
abstract class FMReader(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ENGLISH),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64) Gecko/20100101 Firefox/77.0")
|
||||
add("Referer", baseUrl)
|
||||
}
|
||||
|
||||
protected fun Elements.imgAttr(): String? = getImgAttr(this.firstOrNull())
|
||||
|
||||
private fun Element.imgAttr(): String? = getImgAttr(this)
|
||||
|
||||
open fun getImgAttr(element: Element?): String? {
|
||||
return when {
|
||||
element == null -> null
|
||||
element.hasAttr("data-original") -> element.attr("abs:data-original")
|
||||
element.hasAttr("data-src") -> element.attr("abs:data-src")
|
||||
element.hasAttr("data-bg") -> element.attr("abs:data-bg")
|
||||
element.hasAttr("data-srcset") -> element.attr("abs:data-srcset")
|
||||
else -> element.attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
open val requestPath = "manga-list.html"
|
||||
|
||||
open val popularSort = "sort=views"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/$requestPath?listType=pagination&page=$page&$popularSort&sort_type=DESC", headers)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/$requestPath?".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("name", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is Status -> {
|
||||
val status = arrayOf("", "1", "2")[filter.state]
|
||||
url.addQueryParameter("m_status", status)
|
||||
}
|
||||
is TextField -> url.addQueryParameter(filter.key, filter.state)
|
||||
is GenreList -> {
|
||||
var genre = String()
|
||||
var ungenre = String()
|
||||
filter.state.forEach {
|
||||
if (it.isIncluded()) genre += ",${it.name}"
|
||||
if (it.isExcluded()) ungenre += ",${it.name}"
|
||||
}
|
||||
url.addQueryParameter("genre", genre)
|
||||
url.addQueryParameter("ungenre", ungenre)
|
||||
}
|
||||
is SortBy -> {
|
||||
url.addQueryParameter(
|
||||
"sort",
|
||||
when (filter.state?.index) {
|
||||
0 -> "name"
|
||||
1 -> "views"
|
||||
else -> "last_update"
|
||||
},
|
||||
)
|
||||
if (filter.state?.ascending == true) {
|
||||
url.addQueryParameter("sort_type", "ASC")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/$requestPath?listType=pagination&page=$page&sort=last_update&sort_type=DESC", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(popularMangaSelector()).map { popularMangaFromElement(it) }
|
||||
|
||||
// check if there's a next page
|
||||
val hasNextPage = (document.select(popularMangaNextPageSelector()).first()?.text() ?: "").let {
|
||||
if (it.contains(Regex("""\w*\s\d*\s\w*\s\d*"""))) {
|
||||
it.split(" ").let { pageOf -> pageOf[1] != pageOf[3] } // current page not last page
|
||||
} else {
|
||||
it.isNotEmpty() // standard next page check
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun popularMangaSelector() = "div.media, .thumb-item-flow"
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
open val headerSelector = "h3 a, .series-title a"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
element.select(headerSelector).let {
|
||||
setUrlWithoutDomain(it.attr("abs:href"))
|
||||
title = it.text()
|
||||
}
|
||||
thumbnail_url = element.select("img, .thumb-wrapper .img-in-ratio").imgAttr()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
/**
|
||||
* can select one of 2 different types of elements
|
||||
* one is an element with text "page x of y", must be the first element if it's part of a collection
|
||||
* the other choice is the standard "next page" element (but most FMReader sources don't have this one)
|
||||
*/
|
||||
override fun popularMangaNextPageSelector() = "div.col-lg-9 button.btn-info, .pagination a:contains(»):not(.disabled)"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select("div.row").first()!!
|
||||
|
||||
return SManga.create().apply {
|
||||
author = infoElement.select("li a.btn-info").eachText().filter {
|
||||
it.equals("Updating", true).not()
|
||||
}.joinToString().takeIf { it.isNotBlank() }
|
||||
genre = infoElement.select("li a.btn-danger").joinToString { it.text() }
|
||||
status = parseStatus(infoElement.select("li a.btn-success").first()?.text())
|
||||
description = document.select("div.detail .content, div.row ~ div.row:has(h3:first-child) p, .summary-content p").text().trim()
|
||||
thumbnail_url = infoElement.select("img.thumbnail").imgAttr()
|
||||
|
||||
// add alternative name to manga description
|
||||
infoElement.select(altNameSelector).firstOrNull()?.ownText()?.let {
|
||||
if (it.isBlank().not() && it.contains("Updating", true).not()) {
|
||||
description = when {
|
||||
description.isNullOrBlank() -> altName + it
|
||||
else -> description + "\n\n$altName" + it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open val altNameSelector = "li:contains(Other names)"
|
||||
open val altName = "Alternative Name" // the alt name already contains ": " eg. ": alt name1, alt name2"
|
||||
|
||||
// languages: en, vi, tr
|
||||
fun parseStatus(status: String?): Int {
|
||||
val completedWords = setOf(
|
||||
"completed",
|
||||
"complete",
|
||||
"đã hoàn thành",
|
||||
"hoàn thành",
|
||||
"tamamlandı",
|
||||
)
|
||||
val ongoingWords = setOf(
|
||||
"ongoing", "on going", "updating", "incomplete",
|
||||
"chưa hoàn thành", "đang cập nhật", "Đang tiến hành",
|
||||
"devam ediyor", "Çevirisi Bırakıldı", "Çevirisi Yok",
|
||||
)
|
||||
return when {
|
||||
status == null -> SManga.UNKNOWN
|
||||
completedWords.any { it.equals(status, ignoreCase = true) } -> SManga.COMPLETED
|
||||
ongoingWords.any { it.equals(status, ignoreCase = true) } -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val mangaTitle = document.select(".manga-info h1, .manga-info h3").text()
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it, mangaTitle) }.distinctBy { it.url }
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
return chapterFromElement(element, "")
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div#list-chapters p, table.table tr, .list-chapters > a"
|
||||
|
||||
open val chapterUrlSelector = "a"
|
||||
|
||||
open val chapterTimeSelector = "time, .chapter-time, .publishedDate"
|
||||
|
||||
open val chapterNameAttrSelector = "title"
|
||||
|
||||
open fun chapterFromElement(element: Element, mangaTitle: String = ""): SChapter {
|
||||
return SChapter.create().apply {
|
||||
if (chapterUrlSelector != "") {
|
||||
element.select(chapterUrlSelector).first()!!.let {
|
||||
setUrlWithoutDomain(it.attr("abs:href"))
|
||||
name = it.text().substringAfter("$mangaTitle ")
|
||||
}
|
||||
} else {
|
||||
element.let {
|
||||
setUrlWithoutDomain(it.attr("abs:href"))
|
||||
name = element.attr(chapterNameAttrSelector).substringAfter("$mangaTitle ")
|
||||
}
|
||||
}
|
||||
date_upload = element.select(chapterTimeSelector).let { if (it.hasText()) parseRelativeDate(it.text()) else 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// gets the number from "1 day ago"
|
||||
open val dateValueIndex = 0
|
||||
|
||||
// gets the unit of time (day, week hour) from "1 day ago"
|
||||
open val dateWordIndex = 1
|
||||
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val value = date.split(' ')[dateValueIndex].toInt()
|
||||
val dateWord = date.split(' ')[dateWordIndex].let {
|
||||
if (it.contains("(")) {
|
||||
it.substringBefore("(")
|
||||
} else {
|
||||
it.substringBefore("s")
|
||||
}
|
||||
}
|
||||
|
||||
// languages: en, vi, es, tr
|
||||
return when (dateWord) {
|
||||
"min", "minute", "phút", "minuto", "dakika" -> Calendar.getInstance().apply {
|
||||
add(Calendar.MINUTE, value * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
"hour", "giờ", "hora", "saat" -> Calendar.getInstance().apply {
|
||||
add(Calendar.HOUR_OF_DAY, value * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
"day", "ngày", "día", "gün" -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
"week", "tuần", "semana", "hafta" -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * 7 * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
"month", "tháng", "mes", "ay" -> Calendar.getInstance().apply {
|
||||
add(Calendar.MONTH, value * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
"year", "năm", "año", "yıl" -> Calendar.getInstance().apply {
|
||||
add(Calendar.YEAR, value * -1)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
else -> {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
open fun parseAbsoluteDate(dateStr: String): Long {
|
||||
return runCatching { dateFormat.parse(dateStr)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
open val pageListImageSelector = "img.chapter-img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(pageListImageSelector).mapIndexed { i, img ->
|
||||
Page(i, document.location(), img.imgAttr())
|
||||
}
|
||||
}
|
||||
|
||||
protected fun base64PageListParse(document: Document): List<Page> {
|
||||
fun Element.decoded(): String {
|
||||
val attr =
|
||||
when {
|
||||
this.hasAttr("data-original") -> "data-original"
|
||||
this.hasAttr("data-src") -> "data-src"
|
||||
this.hasAttr("data-srcset") -> "data-srcset"
|
||||
this.hasAttr("data-aload") -> "data-aload"
|
||||
else -> "src"
|
||||
}
|
||||
return if (!this.attr(attr).contains(".")) {
|
||||
Base64.decode(this.attr(attr), Base64.DEFAULT).toString(Charset.defaultCharset())
|
||||
} else {
|
||||
this.attr("abs:$attr")
|
||||
}
|
||||
}
|
||||
|
||||
return document.select(pageListImageSelector).mapIndexed { i, img ->
|
||||
Page(i, document.location(), img.decoded())
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
||||
private class Status : Filter.Select<String>("Status", arrayOf("Any", "Completed", "Ongoing"))
|
||||
class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genre", genres)
|
||||
class Genre(name: String, val id: String = name.replace(' ', '+')) : Filter.TriState(name)
|
||||
private class SortBy : Filter.Sort("Sorted By", arrayOf("A-Z", "Most vỉews", "Last updated"), Selection(1, false))
|
||||
|
||||
// TODO: Country (leftover from original LHTranslation)
|
||||
override fun getFilterList() = FilterList(
|
||||
TextField("Author", "author"),
|
||||
TextField("Group", "group"),
|
||||
Status(),
|
||||
SortBy(),
|
||||
GenreList(getGenreList()),
|
||||
)
|
||||
|
||||
// [...document.querySelectorAll("div.panel-body a")].map((el,i) => `Genre("${el.innerText.trim()}")`).join(',\n')
|
||||
// on https://lhtranslation.net/search
|
||||
open fun getGenreList() = listOf(
|
||||
Genre("Action"),
|
||||
Genre("18+"),
|
||||
Genre("Adult"),
|
||||
Genre("Anime"),
|
||||
Genre("Comedy"),
|
||||
Genre("Comic"),
|
||||
Genre("Doujinshi"),
|
||||
Genre("Drama"),
|
||||
Genre("Ecchi"),
|
||||
Genre("Fantasy"),
|
||||
Genre("Gender Bender"),
|
||||
Genre("Harem"),
|
||||
Genre("Historical"),
|
||||
Genre("Horror"),
|
||||
Genre("Josei"),
|
||||
Genre("Live action"),
|
||||
Genre("Manhua"),
|
||||
Genre("Manhwa"),
|
||||
Genre("Martial Art"),
|
||||
Genre("Mature"),
|
||||
Genre("Mecha"),
|
||||
Genre("Mystery"),
|
||||
Genre("One shot"),
|
||||
Genre("Psychological"),
|
||||
Genre("Romance"),
|
||||
Genre("School Life"),
|
||||
Genre("Sci-fi"),
|
||||
Genre("Seinen"),
|
||||
Genre("Shoujo"),
|
||||
Genre("Shojou Ai"),
|
||||
Genre("Shounen"),
|
||||
Genre("Shounen Ai"),
|
||||
Genre("Slice of Life"),
|
||||
Genre("Smut"),
|
||||
Genre("Sports"),
|
||||
Genre("Supernatural"),
|
||||
Genre("Tragedy"),
|
||||
Genre("Adventure"),
|
||||
Genre("Yaoi"),
|
||||
)
|
||||
|
||||
// from manhwa18.com/search, removed a few that didn't return results/wouldn't be terribly useful
|
||||
fun getAdultGenreList() = listOf(
|
||||
Genre("18"),
|
||||
Genre("Action"),
|
||||
Genre("Adult"),
|
||||
Genre("Adventure"),
|
||||
Genre("Anime"),
|
||||
Genre("Comedy"),
|
||||
Genre("Comic"),
|
||||
Genre("Doujinshi"),
|
||||
Genre("Drama"),
|
||||
Genre("Ecchi"),
|
||||
Genre("Fantasy"),
|
||||
Genre("Gender Bender"),
|
||||
Genre("Harem"),
|
||||
Genre("Historical"),
|
||||
Genre("Horror"),
|
||||
Genre("Josei"),
|
||||
Genre("Live action"),
|
||||
Genre("Magic"),
|
||||
Genre("Manhua"),
|
||||
Genre("Manhwa"),
|
||||
Genre("Martial Arts"),
|
||||
Genre("Mature"),
|
||||
Genre("Mecha"),
|
||||
Genre("Mystery"),
|
||||
Genre("Oneshot"),
|
||||
Genre("Psychological"),
|
||||
Genre("Romance"),
|
||||
Genre("School Life"),
|
||||
Genre("Sci-fi"),
|
||||
Genre("Seinen"),
|
||||
Genre("Shoujo"),
|
||||
Genre("Shoujo Ai"),
|
||||
Genre("Shounen"),
|
||||
Genre("Shounen Ai"),
|
||||
Genre("Slice of life"),
|
||||
Genre("Smut"),
|
||||
Genre("Soft Yaoi"),
|
||||
Genre("Soft Yuri"),
|
||||
Genre("Sports"),
|
||||
Genre("Supernatural"),
|
||||
Genre("Tragedy"),
|
||||
Genre("VnComic"),
|
||||
Genre("Webtoon"),
|
||||
)
|
||||
|
||||
// taken from readcomiconline.org/search
|
||||
fun getComicsGenreList() = listOf(
|
||||
Genre("Action"),
|
||||
Genre("Adventure"),
|
||||
Genre("Anthology"),
|
||||
Genre("Anthropomorphic"),
|
||||
Genre("Biography"),
|
||||
Genre("Children"),
|
||||
Genre("Comedy"),
|
||||
Genre("Crime"),
|
||||
Genre("Drama"),
|
||||
Genre("Family"),
|
||||
Genre("Fantasy"),
|
||||
Genre("Fighting"),
|
||||
Genre("GraphicNovels"),
|
||||
Genre("Historical"),
|
||||
Genre("Horror"),
|
||||
Genre("LeadingLadies"),
|
||||
Genre("LGBTQ"),
|
||||
Genre("Literature"),
|
||||
Genre("Manga"),
|
||||
Genre("MartialArts"),
|
||||
Genre("Mature"),
|
||||
Genre("Military"),
|
||||
Genre("Mystery"),
|
||||
Genre("Mythology"),
|
||||
Genre("Personal"),
|
||||
Genre("Political"),
|
||||
Genre("Post-Apocalyptic"),
|
||||
Genre("Psychological"),
|
||||
Genre("Pulp"),
|
||||
Genre("Religious"),
|
||||
Genre("Robots"),
|
||||
Genre("Romance"),
|
||||
Genre("Schoollife"),
|
||||
Genre("Sci-Fi"),
|
||||
Genre("Sliceoflife"),
|
||||
Genre("Sport"),
|
||||
Genre("Spy"),
|
||||
Genre("Superhero"),
|
||||
Genre("Supernatural"),
|
||||
Genre("Suspense"),
|
||||
Genre("Thriller"),
|
||||
Genre("Vampires"),
|
||||
Genre("VideoGames"),
|
||||
Genre("War"),
|
||||
Genre("Western"),
|
||||
Genre("Zombies"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.kanade.tachiyomi.multisrc.fmreader
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class FMReaderGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "fmreader"
|
||||
|
||||
override val themeClass = "FMReader"
|
||||
|
||||
override val baseVersionCode: Int = 8
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Epik Manga", "https://www.epikmanga.com", "tr"),
|
||||
SingleLang("KissLove", "https://klz9.com", "ja", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("Manga-TR", "https://manga-tr.com", "tr", className = "MangaTR", overrideVersionCode = 2),
|
||||
SingleLang("ManhuaRock", "https://manhuarock.net", "vi", overrideVersionCode = 1),
|
||||
SingleLang("Say Truyen", "https://saytruyenvip.com", "vi", overrideVersionCode = 3),
|
||||
SingleLang("WeLoveManga", "https://weloma.art", "ja", pkgName = "rawlh", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("Manga1000", "https://manga1000.top", "ja"),
|
||||
SingleLang("WeLoveMangaOne", "https://welovemanga.one", "ja", isNsfw = true, overrideVersionCode = 1),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
FMReaderGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package eu.kanade.tachiyomi.multisrc.foolslide
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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.ParsedHttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
abstract class FoolSlide(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
open val urlModifier: String = "",
|
||||
) : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json by lazy { Injekt.get<Json>() }
|
||||
|
||||
override fun popularMangaSelector() = "div.group"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl$urlModifier/directory/$page/", headers)
|
||||
}
|
||||
|
||||
private val latestUpdatesUrls = mutableSetOf<String>()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val mp = super.latestUpdatesParse(response)
|
||||
return mp.copy(
|
||||
mp.mangas.distinctBy { it.url }.filter {
|
||||
latestUpdatesUrls.add(it.url)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = "div.group"
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
if (page == 1) latestUpdatesUrls.clear()
|
||||
return GET("$baseUrl$urlModifier/latest/$page/")
|
||||
}
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
element.select("a[title]").first()!!.let {
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
title = it.text()
|
||||
}
|
||||
element.select("img").first()?.let {
|
||||
thumbnail_url = it.absUrl("src").replace("/thumb_", "/")
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
|
||||
element.select("a[title]").first()!!.let {
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "div.next"
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? = "div.next"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val searchHeaders = headersBuilder().add("Content-Type", "application/x-www-form-urlencoded").build()
|
||||
val form = FormBody.Builder().add("search", query).build()
|
||||
return POST("$baseUrl$urlModifier/search/", searchHeaders, form)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.group"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select("a[title]").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "a:has(span.next)"
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) = allowAdult(super.mangaDetailsRequest(manga))
|
||||
|
||||
protected open val mangaDetailsInfoSelector = "div.info"
|
||||
|
||||
// if there's no image on the details page, get the first page of the first chapter
|
||||
protected fun getDetailsThumbnail(document: Document, urlSelector: String = chapterUrlSelector): String? {
|
||||
return document.select("div.thumbnail img, table.thumb img").firstOrNull()?.attr("abs:src")
|
||||
?: document.select(chapterListSelector()).last()!!.select(urlSelector).attr("abs:href")
|
||||
.let { url -> client.newCall(allowAdult(GET(url))).execute() }
|
||||
.let { response -> pageListParse(response).first().imageUrl }
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
document.select(mangaDetailsInfoSelector).firstOrNull()?.html()?.let { infoHtml ->
|
||||
author = Regex("""(?i)(Author|Autore)</b>:\s?([^\n<]*)[\n<]""").find(infoHtml)?.groupValues?.get(2)
|
||||
artist = Regex("""Artist</b>:\s?([^\n<]*)[\n<]""").find(infoHtml)?.groupValues?.get(1)
|
||||
description = Regex("""(?i)(Synopsis|Description|Trama)</b>:\s?([^\n<]*)[\n<]""").find(infoHtml)?.groupValues?.get(2)
|
||||
}
|
||||
thumbnail_url = getDetailsThumbnail(document)
|
||||
}
|
||||
|
||||
protected open val allowAdult: Boolean
|
||||
get() = preferences.getBoolean("adult", true)
|
||||
|
||||
private fun allowAdult(request: Request): Request {
|
||||
val form = FormBody.Builder().add("adult", allowAdult.toString()).build()
|
||||
return POST(request.url.toString(), headers, form)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = allowAdult(super.chapterListRequest(manga))
|
||||
|
||||
override fun chapterListSelector() = "div.group div.element, div.list div.element"
|
||||
|
||||
protected open val chapterDateSelector = "div.meta_r"
|
||||
|
||||
protected open val chapterUrlSelector = "a[title]"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
val urlElement = element.select(chapterUrlSelector).first()!!
|
||||
val dateElement = element.select(chapterDateSelector).first()!!
|
||||
setUrlWithoutDomain(urlElement.attr("href"))
|
||||
name = urlElement.text()
|
||||
date_upload = parseChapterDate(dateElement.text().substringAfter(", ")) ?: 0
|
||||
}
|
||||
|
||||
protected open fun parseChapterDate(date: String): Long? {
|
||||
val lcDate = date.lowercase(Locale.ROOT)
|
||||
if (lcDate.endsWith(" ago")) {
|
||||
parseRelativeDate(lcDate)?.let { return it }
|
||||
}
|
||||
|
||||
// Handle 'yesterday' and 'today', using midnight
|
||||
var relativeDate: Calendar? = null
|
||||
// Result parsed but no year, copy current year over
|
||||
when {
|
||||
lcDate.startsWith("yesterday") -> {
|
||||
relativeDate = Calendar.getInstance()
|
||||
relativeDate.add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||
relativeDate.set(Calendar.HOUR_OF_DAY, 0)
|
||||
relativeDate.set(Calendar.MINUTE, 0)
|
||||
relativeDate.set(Calendar.SECOND, 0)
|
||||
relativeDate.set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
lcDate.startsWith("today") -> {
|
||||
relativeDate = Calendar.getInstance()
|
||||
relativeDate.set(Calendar.HOUR_OF_DAY, 0)
|
||||
relativeDate.set(Calendar.MINUTE, 0)
|
||||
relativeDate.set(Calendar.SECOND, 0)
|
||||
relativeDate.set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
lcDate.startsWith("tomorrow") -> {
|
||||
relativeDate = Calendar.getInstance()
|
||||
relativeDate.add(Calendar.DAY_OF_MONTH, +1) // tomorrow
|
||||
relativeDate.set(Calendar.HOUR_OF_DAY, 0)
|
||||
relativeDate.set(Calendar.MINUTE, 0)
|
||||
relativeDate.set(Calendar.SECOND, 0)
|
||||
relativeDate.set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
}
|
||||
|
||||
relativeDate?.timeInMillis?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
var result = DATE_FORMAT_1.parseOrNull(date)
|
||||
|
||||
for (dateFormat in DATE_FORMATS_WITH_ORDINAL_SUFFIXES) {
|
||||
if (result == null) {
|
||||
result = dateFormat.parseOrNull(date)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (dateFormat in DATE_FORMATS_WITH_ORDINAL_SUFFIXES_NO_YEAR) {
|
||||
if (result == null) {
|
||||
result = dateFormat.parseOrNull(date)
|
||||
|
||||
if (result != null) {
|
||||
// Result parsed but no year, copy current year over
|
||||
result = Calendar.getInstance().apply {
|
||||
time = result!!
|
||||
set(Calendar.YEAR, Calendar.getInstance().get(Calendar.YEAR))
|
||||
}.time
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result?.time ?: 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses dates in this form:
|
||||
* `11 days ago`
|
||||
*/
|
||||
private fun parseRelativeDate(date: String): Long? {
|
||||
val trimmedDate = date.split(" ")
|
||||
|
||||
if (trimmedDate[2] != "ago") return null
|
||||
|
||||
val number = trimmedDate[0].toIntOrNull() ?: return null
|
||||
val unit = trimmedDate[1].removeSuffix("s") // Remove 's' suffix
|
||||
|
||||
val now = Calendar.getInstance()
|
||||
|
||||
// Map English unit to Java unit
|
||||
val javaUnit = when (unit) {
|
||||
"year", "yr" -> Calendar.YEAR
|
||||
"month" -> Calendar.MONTH
|
||||
"week", "wk" -> Calendar.WEEK_OF_MONTH
|
||||
"day" -> Calendar.DAY_OF_MONTH
|
||||
"hour", "hr" -> Calendar.HOUR
|
||||
"minute", "min" -> Calendar.MINUTE
|
||||
"second", "sec" -> Calendar.SECOND
|
||||
else -> return null
|
||||
}
|
||||
|
||||
now.add(javaUnit, -number)
|
||||
|
||||
return now.timeInMillis
|
||||
}
|
||||
|
||||
private fun SimpleDateFormat.parseOrNull(string: String): Date? {
|
||||
return try {
|
||||
parse(string)
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) = allowAdult(super.pageListRequest(chapter))
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val doc = document.toString()
|
||||
val jsonStr = doc.substringAfter("var pages = ").substringBefore(";")
|
||||
val pages = json.parseToJsonElement(jsonStr).jsonArray
|
||||
return pages.mapIndexed { i, jsonEl ->
|
||||
// Create dummy element to resolve relative URL
|
||||
val absUrl = document.createElement("a")
|
||||
.attr("href", jsonEl.jsonObject["url"]!!.jsonPrimitive.content)
|
||||
.absUrl("href")
|
||||
Page(i, "", absUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
protected val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
CheckBoxPreference(screen.context).apply {
|
||||
key = "adult"
|
||||
summary = "Show adult content"
|
||||
setDefaultValue(true)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ORDINAL_SUFFIXES = listOf("st", "nd", "rd", "th")
|
||||
private val DATE_FORMAT_1 = SimpleDateFormat("yyyy.MM.dd", Locale.US)
|
||||
private val DATE_FORMATS_WITH_ORDINAL_SUFFIXES = ORDINAL_SUFFIXES.map {
|
||||
SimpleDateFormat("dd'$it' MMMM, yyyy", Locale.US)
|
||||
}
|
||||
private val DATE_FORMATS_WITH_ORDINAL_SUFFIXES_NO_YEAR = ORDINAL_SUFFIXES.map {
|
||||
SimpleDateFormat("dd'$it' MMMM", Locale.US)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.multisrc.foolslide
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class FoolSlideGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "foolslide"
|
||||
|
||||
override val themeClass = "FoolSlide"
|
||||
|
||||
override val baseVersionCode: Int = 3
|
||||
|
||||
override val sources = listOf(
|
||||
MultiLang("FoolSlide Customizable", "", listOf("other")),
|
||||
MultiLang("HNI-Scantrad", "https://hni-scantrad.com", listOf("fr", "en"), className = "HNIScantradFactory", pkgName = "hniscantrad", overrideVersionCode = 1),
|
||||
SingleLang("Anata no Motokare", "https://motokare.xyz", "en", className = "AnataNoMotokare"),
|
||||
SingleLang("Baixar Hentai", "https://leitura.baixarhentai.net", "pt-BR", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("Death Toll Scans", "https://reader.deathtollscans.net", "en"),
|
||||
SingleLang("Evil Flowers", "https://reader.evilflowers.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Le Cercle du Scan", "https://lel.lecercleduscan.com", "fr", className = "LeCercleDuScan", overrideVersionCode = 1),
|
||||
SingleLang("Lilyreader", "https://manga.smuglo.li", "en"),
|
||||
SingleLang("MangaScouts", "http://onlinereader.mangascouts.org", "de", overrideVersionCode = 1),
|
||||
SingleLang("Mangatellers", "https://reader.mangatellers.gr", "en"),
|
||||
SingleLang("Menudo-Fansub", "https://www.menudo-fansub.com", "es", className = "MenudoFansub", overrideVersionCode = 1),
|
||||
SingleLang("NIFTeam", "http://read-nifteam.info", "it"),
|
||||
SingleLang("PowerManga", "https://reader.powermanga.org", "it", className = "PowerMangaIT"),
|
||||
SingleLang("Rama", "https://www.ramareader.it", "it"),
|
||||
SingleLang("Silent Sky", "https://reader.silentsky-scans.net", "en"),
|
||||
SingleLang("Wanted Team", "https://reader.onepiecenakama.pl", "pl"),
|
||||
SingleLang("Русификация", "https://rusmanga.ru", "ru", className = "Russification"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
FoolSlideGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gattsu
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class Gattsu(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.add("Accept", ACCEPT)
|
||||
.add("Accept-Language", ACCEPT_LANGUAGE)
|
||||
.add("Referer", baseUrl)
|
||||
|
||||
// Website does not have a popular, so use latest instead.
|
||||
override fun popularMangaRequest(page: Int): Request = latestUpdatesRequest(page)
|
||||
|
||||
override fun popularMangaSelector(): String = latestUpdatesSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = latestUpdatesFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = latestUpdatesNextPageSelector()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val path = if (page == 1) "" else "page/$page"
|
||||
return GET("$baseUrl/$path", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = "div.meio div.lista ul li a[href^=$baseUrl]"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
|
||||
title = element.select("span.thumb-titulo").first()!!.text()
|
||||
thumbnail_url = element.select("span.thumb-imagem img.wp-post-image").first()!!.attr("src")
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = "ul.paginacao li.next > a"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val searchUrl = "$baseUrl/page/$page/".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("s", query)
|
||||
.addQueryParameter("post_type", "post")
|
||||
.toString()
|
||||
|
||||
return GET(searchUrl, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = latestUpdatesFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String = latestUpdatesNextPageSelector()
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
val postBox = document.select("div.meio div.post-box").first()!!
|
||||
|
||||
title = postBox.select("h1.post-titulo").first()!!.text()
|
||||
author = postBox.select("ul.post-itens li:contains(Artista) a").firstOrNull()?.text()
|
||||
genre = postBox.select("ul.post-itens li:contains(Tags) a")
|
||||
.joinToString(", ") { it.text() }
|
||||
description = postBox.select("div.post-texto p")
|
||||
.joinToString("\n\n") { it.text() }
|
||||
.replace("Sinopse :", "")
|
||||
.trim()
|
||||
status = SManga.COMPLETED
|
||||
thumbnail_url = postBox.select("div.post-capa > img.wp-post-image")
|
||||
.attr("src")
|
||||
.withoutSize()
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
if (document.select(pageListSelector()).firstOrNull() == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return document.select(chapterListSelector())
|
||||
.map { chapterFromElement(it) }
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.meio div.post-box:first-of-type"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
name = "Capítulo único"
|
||||
scanlator = element.select("ul.post-itens li:contains(Tradutor) a").firstOrNull()?.text()
|
||||
date_upload = element.ownerDocument()!!.select("meta[property=article:published_time]").firstOrNull()
|
||||
?.attr("content")
|
||||
.orEmpty()
|
||||
.toDate()
|
||||
setUrlWithoutDomain(element.ownerDocument()!!.location())
|
||||
}
|
||||
|
||||
protected open fun pageListSelector(): String =
|
||||
"div.meio div.post-box ul.post-fotos li a > img, " +
|
||||
"div.meio div.post-box.listaImagens div.galeriaHtml img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(pageListSelector())
|
||||
.mapIndexed { i, el ->
|
||||
Page(i, document.location(), el.imgAttr().withoutSize())
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imageHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_IMAGE)
|
||||
.add("Referer", page.url)
|
||||
.build()
|
||||
|
||||
return GET(page.imageUrl!!, imageHeaders)
|
||||
}
|
||||
|
||||
protected fun Element.imgAttr(): String =
|
||||
if (hasAttr("data-src")) {
|
||||
attr("abs:data-src")
|
||||
} else {
|
||||
attr("abs:src")
|
||||
}
|
||||
|
||||
protected fun String.toDate(): Long {
|
||||
return try {
|
||||
DATE_FORMATTER.parse(this.substringBefore("T"))?.time ?: 0L
|
||||
} catch (e: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
protected fun String.withoutSize(): String = this.replace(THUMB_SIZE_REGEX, ".")
|
||||
|
||||
companion object {
|
||||
private const val ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9," +
|
||||
"image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||
private const val ACCEPT_LANGUAGE = "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7,es;q=0.6,gl;q=0.5"
|
||||
|
||||
private val THUMB_SIZE_REGEX = "-\\d+x\\d+\\.".toRegex()
|
||||
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gattsu
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class GattsuGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "gattsu"
|
||||
|
||||
override val themeClass = "Gattsu"
|
||||
|
||||
override val baseVersionCode: Int = 5
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Hentai Season", "https://hentaiseason.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Hentai Tokyo", "https://hentaitokyo.net", "pt-BR", isNsfw = true),
|
||||
SingleLang("Universo Hentai", "https://universohentai.com", "pt-BR", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
GattsuGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gigaviewer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Call
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.floor
|
||||
|
||||
abstract class GigaViewer(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val cdnUrl: String = "",
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
protected val dayOfWeek: String by lazy {
|
||||
Calendar.getInstance()
|
||||
.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.US)!!
|
||||
.lowercase(Locale.US)
|
||||
}
|
||||
|
||||
protected open val publisher: String = ""
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("Origin", baseUrl)
|
||||
.add("Referer", baseUrl)
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/series", headers)
|
||||
|
||||
override fun popularMangaSelector(): String = "ul.series-list li a"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
title = element.selectFirst("h2.series-list-title")!!.text()
|
||||
thumbnail_url = element.selectFirst("div.series-list-thumb img")!!
|
||||
.attr("data-src")
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = popularMangaRequest(page)
|
||||
|
||||
override fun latestUpdatesSelector(): String = "h2.series-list-date-week.$dayOfWeek + ul.series-list li a"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? = null
|
||||
|
||||
// The search returns 404 when there's no results.
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableIgnoreCode(404)
|
||||
.map(::searchMangaParse)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotEmpty()) {
|
||||
val url = "$baseUrl/search".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("q", query)
|
||||
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
val collectionSelected = (filters[0] as CollectionFilter).selected
|
||||
val collectionPath = if (collectionSelected.path.isBlank()) "" else "/" + collectionSelected.path
|
||||
return GET("$baseUrl/series$collectionPath", headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (response.request.url.toString().contains("search")) {
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
return popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "ul.search-series-list li, ul.series-list li"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
title = element.selectFirst("div.title-box p.series-title")!!.text()
|
||||
thumbnail_url = element.selectFirst("div.thmb-container a img")!!.attr("src")
|
||||
setUrlWithoutDomain(element.selectFirst("div.thmb-container a")!!.attr("href"))
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector(): String? = null
|
||||
|
||||
protected open fun mangaDetailsInfoSelector(): String = "section.series-information div.series-header"
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
val infoElement = document.selectFirst(mangaDetailsInfoSelector())!!
|
||||
|
||||
title = infoElement.selectFirst("h1.series-header-title")!!.text()
|
||||
author = infoElement.selectFirst("h2.series-header-author")!!.text()
|
||||
artist = author
|
||||
description = infoElement.selectFirst("p.series-header-description")!!.text()
|
||||
thumbnail_url = infoElement.selectFirst("div.series-header-image-wrapper img")!!
|
||||
.attr("data-src")
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val readableProductList = document.selectFirst("div.js-readable-product-list")!!
|
||||
val firstListEndpoint = readableProductList.attr("data-first-list-endpoint")
|
||||
.toHttpUrlOrNull()!!
|
||||
val latestListEndpoint = readableProductList.attr("data-latest-list-endpoint")
|
||||
.toHttpUrlOrNull() ?: firstListEndpoint
|
||||
val numberSince = latestListEndpoint.queryParameter("number_since")!!.toFloat()
|
||||
.coerceAtLeast(firstListEndpoint.queryParameter("number_since")!!.toFloat())
|
||||
|
||||
val newHeaders = headers.newBuilder()
|
||||
.set("Referer", response.request.url.toString())
|
||||
.build()
|
||||
var readMoreEndpoint = firstListEndpoint.newBuilder()
|
||||
.setQueryParameter("number_since", numberSince.toString())
|
||||
.toString()
|
||||
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
|
||||
var request = GET(readMoreEndpoint, newHeaders)
|
||||
var result = client.newCall(request).execute()
|
||||
|
||||
while (result.code != 404) {
|
||||
val jsonResult = json.parseToJsonElement(result.body.string()).jsonObject
|
||||
readMoreEndpoint = jsonResult["nextUrl"]!!.jsonPrimitive.content
|
||||
val tempDocument = Jsoup.parse(
|
||||
jsonResult["html"]!!.jsonPrimitive.content,
|
||||
response.request.url.toString(),
|
||||
)
|
||||
|
||||
tempDocument
|
||||
.select("ul.series-episode-list " + chapterListSelector())
|
||||
.mapTo(chapters) { element -> chapterFromElement(element) }
|
||||
|
||||
request = GET(readMoreEndpoint, newHeaders)
|
||||
result = client.newCall(request).execute()
|
||||
}
|
||||
|
||||
result.close()
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "li.episode"
|
||||
|
||||
protected open val chapterListMode = CHAPTER_LIST_PAID
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val info = element.selectFirst("a.series-episode-list-container") ?: element
|
||||
val mangaUrl = element.ownerDocument()!!.location()
|
||||
|
||||
return SChapter.create().apply {
|
||||
name = info.selectFirst("h4.series-episode-list-title")!!.text()
|
||||
if (chapterListMode == CHAPTER_LIST_PAID && element.selectFirst("span.series-episode-list-is-free") == null) {
|
||||
name = YEN_BANKNOTE + name
|
||||
} else if (chapterListMode == CHAPTER_LIST_LOCKED && element.hasClass("private")) {
|
||||
name = LOCK + name
|
||||
}
|
||||
date_upload = info.selectFirst("span.series-episode-list-date")
|
||||
?.text().orEmpty()
|
||||
.toDate()
|
||||
scanlator = publisher
|
||||
setUrlWithoutDomain(if (info.tagName() == "a") info.attr("href") else mangaUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val episode = document.selectFirst("script#episode-json")!!
|
||||
.attr("data-value")
|
||||
.let {
|
||||
try {
|
||||
json.decodeFromString<GigaViewerEpisodeDto>(it)
|
||||
} catch (e: SerializationException) {
|
||||
throw Exception("このチャプターは非公開です\nChapter is not available!")
|
||||
}
|
||||
}
|
||||
|
||||
return episode.readableProduct.pageStructure.pages
|
||||
.filter { it.type == "main" }
|
||||
.mapIndexed { i, page ->
|
||||
val imageUrl = page.src.toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("width", page.width.toString())
|
||||
.addQueryParameter("height", page.height.toString())
|
||||
.toString()
|
||||
Page(i, document.location(), imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", page.url)
|
||||
.build()
|
||||
|
||||
return GET(page.imageUrl!!, newHeaders)
|
||||
}
|
||||
|
||||
protected data class Collection(val name: String, val path: String) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
private class CollectionFilter(val collections: List<Collection>) : Filter.Select<Collection>(
|
||||
"コレクション",
|
||||
collections.toTypedArray(),
|
||||
) {
|
||||
val selected: Collection
|
||||
get() = collections[state]
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(CollectionFilter(getCollections()))
|
||||
|
||||
protected open fun getCollections(): List<Collection> = emptyList()
|
||||
|
||||
protected open fun imageIntercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
|
||||
if (!request.url.toString().startsWith(cdnUrl)) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val width = request.url.queryParameter("width")!!.toInt()
|
||||
val height = request.url.queryParameter("height")!!.toInt()
|
||||
|
||||
val newUrl = request.url.newBuilder()
|
||||
.removeAllQueryParameters("width")
|
||||
.removeAllQueryParameters("height")
|
||||
.build()
|
||||
request = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(request)
|
||||
val image = decodeImage(response.body.byteStream(), width, height)
|
||||
val body = image.toResponseBody(jpegMediaType)
|
||||
|
||||
response.close()
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
protected open fun decodeImage(image: InputStream, width: Int, height: Int): ByteArray {
|
||||
val input = BitmapFactory.decodeStream(image)
|
||||
val cWidth = (floor(width.toDouble() / (DIVIDE_NUM * MULTIPLE)) * MULTIPLE).toInt()
|
||||
val cHeight = (floor(height.toDouble() / (DIVIDE_NUM * MULTIPLE)) * MULTIPLE).toInt()
|
||||
|
||||
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
|
||||
val imageRect = Rect(0, 0, width, height)
|
||||
canvas.drawBitmap(input, imageRect, imageRect, null)
|
||||
|
||||
for (e in 0 until DIVIDE_NUM * DIVIDE_NUM) {
|
||||
val x = e % DIVIDE_NUM * cWidth
|
||||
val y = (floor(e.toFloat() / DIVIDE_NUM) * cHeight).toInt()
|
||||
val cellSrc = Rect(x, y, x + cWidth, y + cHeight)
|
||||
|
||||
val row = floor(e.toFloat() / DIVIDE_NUM).toInt()
|
||||
val dstE = e % DIVIDE_NUM * DIVIDE_NUM + row
|
||||
val dstX = dstE % DIVIDE_NUM * cWidth
|
||||
val dstY = (floor(dstE.toFloat() / DIVIDE_NUM) * cHeight).toInt()
|
||||
val cellDst = Rect(dstX, dstY, dstX + cWidth, dstY + cHeight)
|
||||
canvas.drawBitmap(input, cellSrc, cellDst, null)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun Call.asObservableIgnoreCode(code: Int): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful && response.code != code) {
|
||||
response.close()
|
||||
throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDate(): Long {
|
||||
return runCatching { DATE_PARSER.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_PARSER by lazy { SimpleDateFormat("yyyy/MM/dd", Locale.ENGLISH) }
|
||||
|
||||
private const val DIVIDE_NUM = 4
|
||||
private const val MULTIPLE = 8
|
||||
private val jpegMediaType = "image/jpeg".toMediaType()
|
||||
|
||||
const val CHAPTER_LIST_PAID = 0
|
||||
const val CHAPTER_LIST_LOCKED = 1
|
||||
|
||||
private const val YEN_BANKNOTE = "💴 "
|
||||
private const val LOCK = "🔒 "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gigaviewer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GigaViewerEpisodeDto(
|
||||
val readableProduct: GigaViewerReadableProduct,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GigaViewerReadableProduct(
|
||||
val pageStructure: GigaViewerPageStructure,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GigaViewerPageStructure(
|
||||
val pages: List<GigaViewerPage> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GigaViewerPage(
|
||||
val height: Int = 0,
|
||||
val src: String = "",
|
||||
val type: String = "",
|
||||
val width: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gigaviewer
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class GigaViewerGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "gigaviewer"
|
||||
|
||||
override val themeClass = "GigaViewer"
|
||||
|
||||
override val baseVersionCode: Int = 5
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Comic Days", "https://comic-days.com", "ja"),
|
||||
SingleLang("Comic Gardo", "https://comic-gardo.com", "ja"),
|
||||
SingleLang("Comiplex", "https://viewer.heros-web.com", "ja"),
|
||||
SingleLang("Corocoro Online", "https://corocoro.jp", "ja"),
|
||||
SingleLang("Kurage Bunch", "https://kuragebunch.com", "ja"),
|
||||
SingleLang("MAGCOMI", "https://magcomi.com", "ja", className = "MagComi"),
|
||||
SingleLang("Magazine Pocket", "https://pocket.shonenmagazine.com", "ja"),
|
||||
SingleLang("Shonen Jump+", "https://shonenjumpplus.com", "ja", pkgName = "shonenjumpplus", className = "ShonenJumpPlus", overrideVersionCode = 2),
|
||||
SingleLang("Sunday Web Every", "https://www.sunday-webry.com", "ja"),
|
||||
SingleLang("Tonari no Young Jump", "https://tonarinoyj.jp", "ja", className = "TonariNoYoungJump"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
GigaViewerGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package eu.kanade.tachiyomi.multisrc.grouple
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.text.DecimalFormat
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
abstract class GroupLe(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
) : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
val response = chain.proceed(originalRequest)
|
||||
if (originalRequest.url.toString().contains(baseUrl) && (
|
||||
originalRequest.url.toString()
|
||||
.contains("internal/redirect") or (response.code == 301)
|
||||
)
|
||||
) {
|
||||
throw IOException("Ссылка на мангу была изменена. Перемигрируйте мангу на тот же (или смежный с GroupLe) источник или передобавьте из Поисковика/Каталога.")
|
||||
}
|
||||
response
|
||||
}
|
||||
.build()
|
||||
|
||||
private var uagent: String = preferences.getString(UAGENT_TITLE, UAGENT_DEFAULT)!!
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", uagent)
|
||||
add("Referer", baseUrl)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "div.tile"
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}", headers)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}", headers)
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url =
|
||||
element.select("img.lazy").first()?.attr("data-original")?.replace("_p.", ".")
|
||||
element.select("h3 > a").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||
popularMangaFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url =
|
||||
"$baseUrl/search/advancedResults?offset=${50 * (page - 1)}".toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
if (query.isNotEmpty()) {
|
||||
url.addQueryParameter("q", query)
|
||||
}
|
||||
return GET(url.toString().replace("=%3D", "="), headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select(".expandable").first()!!
|
||||
val rawCategory = infoElement.select("span.elem_category").text()
|
||||
val category = rawCategory.ifEmpty {
|
||||
"манга"
|
||||
}
|
||||
|
||||
val ratingValue =
|
||||
infoElement.select(".rating-block").attr("data-score").toFloat() * 2
|
||||
val ratingValueOver =
|
||||
infoElement.select(".info-icon").attr("data-content").substringBeforeLast("/5</b><br/>")
|
||||
.substringAfterLast(": <b>").replace(",", ".").toFloat() * 2
|
||||
val ratingVotes =
|
||||
infoElement.select(".col-sm-7 .user-rating meta[itemprop=\"ratingCount\"]")
|
||||
.attr("content")
|
||||
val ratingStar = when {
|
||||
ratingValue > 9.5 -> "★★★★★"
|
||||
ratingValue > 8.5 -> "★★★★✬"
|
||||
ratingValue > 7.5 -> "★★★★☆"
|
||||
ratingValue > 6.5 -> "★★★✬☆"
|
||||
ratingValue > 5.5 -> "★★★☆☆"
|
||||
ratingValue > 4.5 -> "★★✬☆☆"
|
||||
ratingValue > 3.5 -> "★★☆☆☆"
|
||||
ratingValue > 2.5 -> "★✬☆☆☆"
|
||||
ratingValue > 1.5 -> "★☆☆☆☆"
|
||||
ratingValue > 0.5 -> "✬☆☆☆☆"
|
||||
else -> "☆☆☆☆☆"
|
||||
}
|
||||
val rawAgeStop = when (
|
||||
val rawAgeValue =
|
||||
infoElement.select(".elem_limitation .element-link").first()?.text() ?: ""
|
||||
) {
|
||||
"NC-17" -> "18+"
|
||||
"R18+" -> "18+"
|
||||
"R" -> "16+"
|
||||
"G" -> "16+"
|
||||
"PG" -> "16+"
|
||||
"PG-13" -> "12+"
|
||||
else -> rawAgeValue
|
||||
}
|
||||
val manga = SManga.create()
|
||||
manga.title = document.select(".names > .name").text()
|
||||
manga.author = infoElement.select("span.elem_author").first()?.text() ?: infoElement.select(
|
||||
"span.elem_screenwriter",
|
||||
).first()?.text()
|
||||
manga.artist = infoElement.select("span.elem_illustrator").first()?.text()
|
||||
manga.genre = (
|
||||
"$category, $rawAgeStop, " + infoElement.select("p:contains(Жанры:) a, p:contains(Теги:) a")
|
||||
.joinToString { it.text() }
|
||||
).split(", ")
|
||||
.filter { it.isNotEmpty() }.joinToString { it.trim().lowercase() }
|
||||
val altName = if (infoElement.select(".another-names").isNotEmpty()) {
|
||||
"Альтернативные названия:\n" + infoElement.select(".another-names").text() + "\n\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
manga.description =
|
||||
"$ratingStar $ratingValue[ⓘ$ratingValueOver] (голосов: $ratingVotes)\n$altName" + document.select(
|
||||
"div#tab-description .manga-description",
|
||||
).text()
|
||||
manga.status = when {
|
||||
infoElement.html()
|
||||
.contains("Запрещена публикация произведения по копирайту") || infoElement.html()
|
||||
.contains("ЗАПРЕЩЕНА К ПУБЛИКАЦИИ НА ТЕРРИТОРИИ РФ!") -> SManga.LICENSED
|
||||
infoElement.html().contains("<b>Сингл</b>") -> SManga.COMPLETED
|
||||
else ->
|
||||
when (infoElement.select("p:contains(Перевод:) span").first()?.text()) {
|
||||
"продолжается" -> SManga.ONGOING
|
||||
"начат" -> SManga.ONGOING
|
||||
"переведено" -> SManga.COMPLETED
|
||||
"завершён" -> SManga.COMPLETED
|
||||
"приостановлен" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
manga.thumbnail_url = infoElement.select("img").attr("data-full")
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return if (manga.status != SManga.LICENSED) {
|
||||
client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
chapterListParse(response, manga)
|
||||
}
|
||||
} else {
|
||||
Observable.error(java.lang.Exception("Лицензировано - Нет глав"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
if ((document.select(".expandable.hide-dn").isNotEmpty() && document.select(".user-avatar").isNullOrEmpty() && document.toString().contains("current_user_country_code = 'RU'")) || (document.select("img.logo").first()?.attr("title")?.contains("Allhentai") == true && document.select(".user-avatar").isNullOrEmpty())) {
|
||||
throw Exception("Для просмотра контента необходима авторизация через WebView\uD83C\uDF0E")
|
||||
}
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it, manga) }
|
||||
}
|
||||
|
||||
override fun chapterListSelector() =
|
||||
"tr.item-row:has(td > a):has(td.date:not(.text-info))"
|
||||
|
||||
private fun chapterFromElement(element: Element, manga: SManga): SChapter {
|
||||
val urlElement = element.select("a.chapter-link").first()!!
|
||||
val chapterInf = element.select("td.item-title").first()!!
|
||||
val urlText = urlElement.text()
|
||||
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mtr=true") // mtr is 18+ fractional skip
|
||||
|
||||
val translatorElement = urlElement.attr("title")
|
||||
|
||||
chapter.scanlator = if (!translatorElement.isNullOrBlank()) {
|
||||
translatorElement
|
||||
.replace("(Переводчик),", "&")
|
||||
.removeSuffix(" (Переводчик)")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
chapter.name = urlText.removeSuffix(" новое").trim()
|
||||
if (manga.title.length > 25) {
|
||||
for (word in manga.title.split(' ')) {
|
||||
chapter.name = chapter.name.removePrefix(word).trim()
|
||||
}
|
||||
}
|
||||
val dots = chapter.name.indexOf("…")
|
||||
val numbers = chapter.name.findAnyOf(IntRange(0, 9).map { it.toString() })?.first ?: 0
|
||||
|
||||
if (dots in 0 until numbers) {
|
||||
chapter.name = chapter.name.substringAfter("…").trim()
|
||||
}
|
||||
|
||||
chapter.chapter_number = chapterInf.attr("data-num").toFloat() / 10
|
||||
|
||||
chapter.date_upload = element.select("td.d-none").last()?.text()?.let {
|
||||
try {
|
||||
SimpleDateFormat("dd.MM.yy", Locale.US).parse(it)?.time ?: 0L
|
||||
} catch (e: ParseException) {
|
||||
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it)?.time ?: 0L
|
||||
}
|
||||
} ?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
throw Exception("Not used")
|
||||
}
|
||||
|
||||
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
||||
val extra = Regex("""\s*([0-9]+\sЭкстра)\s*""")
|
||||
val single = Regex("""\s*Сингл\s*""")
|
||||
when {
|
||||
extra.containsMatchIn(chapter.name) -> {
|
||||
if (chapter.name.substringAfter("Экстра").trim().isEmpty()) {
|
||||
chapter.name = chapter.name.replaceFirst(
|
||||
" ",
|
||||
" - " + DecimalFormat("#,###.##").format(chapter.chapter_number)
|
||||
.replace(",", ".") + " ",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
single.containsMatchIn(chapter.name) -> {
|
||||
if (chapter.name.substringAfter("Сингл").trim().isEmpty()) {
|
||||
chapter.name = DecimalFormat("#,###.##").format(chapter.chapter_number)
|
||||
.replace(",", ".") + " " + chapter.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val html = document.html()
|
||||
|
||||
var readerMark = "rm_h.initReader( ["
|
||||
|
||||
if (!html.contains(readerMark)) {
|
||||
readerMark = "rm_h.readerInit( 0,["
|
||||
}
|
||||
|
||||
if (!html.contains(readerMark)) {
|
||||
if (document.select(".input-lg").isNotEmpty() || (document.select(".user-avatar").isNullOrEmpty() && document.select("img.logo").first()?.attr("title")?.contains("Allhentai") == true)) {
|
||||
throw Exception("Для просмотра контента необходима авторизация через WebView\uD83C\uDF0E")
|
||||
}
|
||||
if (!response.request.url.toString().contains(baseUrl)) {
|
||||
throw Exception("Не удалось загрузить главу. Url: ${response.request.url}")
|
||||
}
|
||||
}
|
||||
|
||||
val beginIndex = html.indexOf(readerMark)
|
||||
val endIndex = html.indexOf(");", beginIndex)
|
||||
val trimmedHtml = html.substring(beginIndex, endIndex)
|
||||
|
||||
val p = Pattern.compile("'.*?','.*?',\".*?\"")
|
||||
val m = p.matcher(trimmedHtml)
|
||||
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
|
||||
var url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
|
||||
baseUrl + urlParts[2]
|
||||
} else {
|
||||
if (urlParts[1].endsWith("/manga/")) {
|
||||
urlParts[0] + urlParts[2]
|
||||
} else {
|
||||
urlParts[1] + urlParts[0] + urlParts[2]
|
||||
}
|
||||
}
|
||||
if (!url.contains("://")) {
|
||||
url = "https:$url"
|
||||
}
|
||||
if (url.contains("one-way.work")) {
|
||||
// domain that does not need a token
|
||||
url = url.substringBefore("?")
|
||||
}
|
||||
pages.add(Page(i++, "", url.replace("//resh", "//h")))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
throw Exception("Not used")
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imgHeader = Headers.Builder().apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
||||
add("Referer", baseUrl)
|
||||
}.build()
|
||||
return GET(page.imageUrl!!, imgHeader)
|
||||
}
|
||||
|
||||
private fun searchMangaByIdRequest(id: String): Request {
|
||||
return GET("$baseUrl/$id", headers)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
|
||||
val realQuery = query.removePrefix(PREFIX_SLUG_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(realQuery))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/$realQuery"
|
||||
MangasPage(listOf(details), false)
|
||||
}
|
||||
} else {
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
screen.addPreference(screen.editTextPreference(UAGENT_TITLE, UAGENT_DEFAULT, uagent))
|
||||
}
|
||||
|
||||
private fun androidx.preference.PreferenceScreen.editTextPreference(
|
||||
title: String,
|
||||
default: String,
|
||||
value: String,
|
||||
): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Для смены User-Agent необходимо перезапустить приложение с полной остановкой.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val UAGENT_TITLE = "User-Agent(для некоторых стран)"
|
||||
private const val UAGENT_DEFAULT = "arora"
|
||||
const val PREFIX_SLUG_SEARCH = "slug:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.multisrc.grouple
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class GroupLeGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "grouple"
|
||||
|
||||
override val themeClass = "GroupLe"
|
||||
|
||||
override val baseVersionCode = 20
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("ReadManga", "https://readmanga.live", "ru", overrideVersionCode = 46),
|
||||
SingleLang("MintManga", "https://mintmanga.com", "ru", isNsfw = true, overrideVersionCode = 46),
|
||||
SingleLang("AllHentai", "https://z.allhen.online", "ru", isNsfw = true, overrideVersionCode = 22),
|
||||
SingleLang("SelfManga", "https://selfmanga.live", "ru", overrideVersionCode = 22),
|
||||
SingleLang("RuMIX", "https://rumix.me", "ru", overrideVersionCode = 1),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
GroupLeGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.kanade.tachiyomi.multisrc.grouple
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
/**
|
||||
* Springboard that accepts https://readmanga.live/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 GroupLeUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 0) {
|
||||
val titleid = pathSegments[0]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${GroupLe.PREFIX_SLUG_SEARCH}$titleid")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("GroupLeUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("GroupLeUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
481
multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/guya/Guya.kt
Normal file
481
multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/guya/Guya.kt
Normal file
@@ -0,0 +1,481 @@
|
||||
package eu.kanade.tachiyomi.multisrc.guya
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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 okhttp3.Headers
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
abstract class Guya(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val scanlatorCacheUrl by lazy { "$baseUrl/api/get_all_groups/" }
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().add(
|
||||
"User-Agent",
|
||||
"(Android ${Build.VERSION.RELEASE}; " +
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}) " +
|
||||
"Tachiyomi/${AppInfo.getVersionName()} ${Build.ID}",
|
||||
)
|
||||
|
||||
private val scanlators: ScanlatorStore = ScanlatorStore()
|
||||
|
||||
// Preferences configuration
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
// Request builder for the "browse" page of the manga
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/api/get_all_series/", headers)
|
||||
}
|
||||
|
||||
// Gets the response object from the request
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val res = response.body.string()
|
||||
return parseManga(JSONObject(res))
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return popularMangaRequest(page)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val payload = JSONObject(response.body.string())
|
||||
val mangas = sortedMapOf<Long, SManga>()
|
||||
|
||||
for (series in payload.keys()) {
|
||||
val json = payload.getJSONObject(series)
|
||||
val timestamp = json.getLong("last_updated")
|
||||
mangas[timestamp] = parseMangaFromJson(json, series)
|
||||
}
|
||||
|
||||
return MangasPage(mangas.values.reversed(), false)
|
||||
}
|
||||
|
||||
// Overridden to use our overload
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mangaDetailsParse(response, manga)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl/api/series/${manga.url}/", headers)
|
||||
}
|
||||
|
||||
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
|
||||
val res = response.body.string()
|
||||
return parseMangaFromJson(JSONObject(res), manga.title)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl/reader/series/${manga.url}/"
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
chapterListParse(response, manga)
|
||||
}
|
||||
}
|
||||
|
||||
// Gets the chapter list based on the series being viewed
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl/api/series/${manga.url}/", headers)
|
||||
}
|
||||
|
||||
// Called after the request
|
||||
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
|
||||
return parseChapterList(response.body.string(), manga)
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
return "$baseUrl/read/manga/${chapter.url.replace('.', '-')}/1/"
|
||||
}
|
||||
|
||||
// Overridden fetch so that we use our overloaded method instead
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
pageListParse(response, chapter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET("$baseUrl/api/series/${chapter.url.split("/")[0]}/", headers)
|
||||
}
|
||||
|
||||
private fun pageListParse(response: Response, chapter: SChapter): List<Page> {
|
||||
val res = response.body.string()
|
||||
|
||||
val json = JSONObject(res)
|
||||
val chapterNum = chapter.name.split(" - ")[0]
|
||||
val pages = json.getJSONObject("chapters")
|
||||
.getJSONObject(chapterNum)
|
||||
.getJSONObject("groups")
|
||||
val metadata = JSONObject()
|
||||
|
||||
metadata.put("chapter", chapterNum)
|
||||
metadata.put("scanlator", scanlators.getKeyFromValue(chapter.scanlator ?: ""))
|
||||
metadata.put("slug", json.getString("slug"))
|
||||
metadata.put(
|
||||
"folder",
|
||||
json.getJSONObject("chapters")
|
||||
.getJSONObject(chapterNum)
|
||||
.getString("folder"),
|
||||
)
|
||||
|
||||
return parsePageFromJson(pages, metadata)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(SLUG_PREFIX) -> {
|
||||
val slug = query.removePrefix(SLUG_PREFIX)
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParseWithSlug(response, slug)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/api/get_all_series/", headers)
|
||||
}
|
||||
|
||||
protected open fun searchMangaParseWithSlug(response: Response, slug: String): MangasPage {
|
||||
val results = JSONObject(response.body.string())
|
||||
val truncatedJSON = JSONObject()
|
||||
|
||||
for (mangaTitle in results.keys()) {
|
||||
val mangaDetails = results.getJSONObject(mangaTitle)
|
||||
|
||||
if (mangaDetails.get("slug") == slug) {
|
||||
truncatedJSON.put(mangaTitle, mangaDetails)
|
||||
}
|
||||
}
|
||||
|
||||
return parseManga(truncatedJSON)
|
||||
}
|
||||
|
||||
protected open fun searchMangaParse(response: Response, query: String): MangasPage {
|
||||
val res = response.body.string()
|
||||
val json = JSONObject(res)
|
||||
val truncatedJSON = JSONObject()
|
||||
|
||||
for (candidate in json.keys()) {
|
||||
if (candidate.contains(query, ignoreCase = true)) {
|
||||
truncatedJSON.put(candidate, json.get(candidate))
|
||||
}
|
||||
}
|
||||
|
||||
return parseManga(truncatedJSON)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val scanlatorKeys = scanlators.keys().toTypedArray()
|
||||
|
||||
val preference = ListPreference(screen.context).apply {
|
||||
key = "preferred_scanlator"
|
||||
title = "Preferred scanlator"
|
||||
entries = Array(scanlatorKeys.size) { scanlators.getValueFromKey(scanlatorKeys[it]) }
|
||||
entryValues = scanlatorKeys
|
||||
summary = "Current: %s\n\n" +
|
||||
"This setting sets the scanlation group to prioritize " +
|
||||
"on chapter refresh/update. It will get the next available if " +
|
||||
"your preferred scanlator isn't an option (yet)."
|
||||
|
||||
setDefaultValue("1")
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue.toString()
|
||||
preferences.edit().putString(scanlatorPreference, selected).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(preference)
|
||||
}
|
||||
|
||||
// ------------- Helpers and whatnot ---------------
|
||||
|
||||
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
|
||||
val sortKey = "preferred_sort"
|
||||
val response = JSONObject(payload)
|
||||
val chapters = response.getJSONObject("chapters")
|
||||
val mapping = response.getJSONObject("groups")
|
||||
|
||||
val chapterList = mutableListOf<SChapter>()
|
||||
|
||||
val iter = chapters.keys()
|
||||
|
||||
while (iter.hasNext()) {
|
||||
val chapterNum = iter.next()
|
||||
val chapterObj = chapters.getJSONObject(chapterNum)
|
||||
when {
|
||||
chapterObj.has(sortKey) -> {
|
||||
chapterList.add(
|
||||
parseChapterFromJson(
|
||||
chapterObj,
|
||||
chapterNum,
|
||||
chapterObj.getJSONArray(sortKey),
|
||||
response.getString("slug"),
|
||||
),
|
||||
)
|
||||
}
|
||||
response.has(sortKey) -> {
|
||||
chapterList.add(
|
||||
parseChapterFromJson(
|
||||
chapterObj,
|
||||
chapterNum,
|
||||
response.getJSONArray(sortKey),
|
||||
response.getString("slug"),
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val groups = chapterObj.getJSONObject("groups")
|
||||
val groupsIter = groups.keys()
|
||||
|
||||
while (groupsIter.hasNext()) {
|
||||
val chapter = SChapter.create()
|
||||
val groupNum = groupsIter.next()
|
||||
|
||||
chapter.scanlator = mapping.getString(groupNum)
|
||||
if (chapterObj.has("release_date")) {
|
||||
chapter.date_upload =
|
||||
chapterObj.getJSONObject("release_date").getLong(groupNum) * 1000
|
||||
}
|
||||
chapter.name = chapterNum + " - " + chapterObj.getString("title")
|
||||
chapter.chapter_number = chapterNum.toFloat()
|
||||
chapter.url = "${manga.url}/$chapterNum"
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chapterList.reversed()
|
||||
}
|
||||
|
||||
// Helper function to get all the listings
|
||||
private fun parseManga(payload: JSONObject): MangasPage {
|
||||
val mangas = mutableListOf<SManga>()
|
||||
|
||||
for (series in payload.keys()) {
|
||||
val json = payload.getJSONObject(series)
|
||||
mangas += parseMangaFromJson(json, series)
|
||||
}
|
||||
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
// Takes a json of the manga to parse
|
||||
private fun parseMangaFromJson(json: JSONObject, title: String = ""): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = title.ifEmpty { json.getString("title") }
|
||||
manga.artist = json.optString("artist")
|
||||
manga.author = json.optString("author")
|
||||
manga.description = json.optString("description").let {
|
||||
if ('<' !in it) return@let it // no HTML
|
||||
Jsoup.parseBodyFragment(it).body().run {
|
||||
select(Evaluator.Tag("a")).remove()
|
||||
text()
|
||||
}
|
||||
}
|
||||
manga.url = json.getString("slug")
|
||||
|
||||
val cover = json.optString("cover")
|
||||
manga.thumbnail_url = when {
|
||||
cover.startsWith("http") -> cover
|
||||
cover.isNotEmpty() -> "$baseUrl/$cover"
|
||||
else -> null
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseChapterFromJson(json: JSONObject, num: String, sort: JSONArray, slug: String): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
|
||||
// Get the scanlator info based on group ranking; do it first since we need it later
|
||||
val firstGroupId = getBestScanlator(json.getJSONObject("groups"), sort)
|
||||
chapter.scanlator = scanlators.getValueFromKey(firstGroupId)
|
||||
chapter.date_upload = json.getJSONObject("release_date").getLong(firstGroupId) * 1000
|
||||
chapter.name = num + " - " + json.getString("title")
|
||||
chapter.chapter_number = num.toFloat()
|
||||
chapter.url = "$slug/$num"
|
||||
|
||||
return chapter
|
||||
}
|
||||
|
||||
private fun parsePageFromJson(json: JSONObject, metadata: JSONObject): List<Page> {
|
||||
val pages = json.getJSONArray(metadata.getString("scanlator"))
|
||||
return List(pages.length()) {
|
||||
Page(
|
||||
it + 1,
|
||||
"",
|
||||
pageBuilder(
|
||||
metadata.getString("slug"),
|
||||
metadata.getString("folder"),
|
||||
pages[it].toString(),
|
||||
metadata.getString("scanlator"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBestScanlator(json: JSONObject, sort: JSONArray): String {
|
||||
val preferred = preferences.getString(scanlatorPreference, null)
|
||||
|
||||
if (preferred != null && json.has(preferred)) {
|
||||
return preferred
|
||||
} else {
|
||||
for (i in 0 until sort.length()) {
|
||||
if (json.has(sort.get(i).toString())) {
|
||||
return sort.get(i).toString()
|
||||
}
|
||||
}
|
||||
// If all fails, fall-back to the next available key
|
||||
return json.keys().next()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pageBuilder(slug: String, folder: String, filename: String, groupId: String): String {
|
||||
return "$baseUrl/media/manga/$slug/chapters/$folder/$groupId/$filename"
|
||||
}
|
||||
|
||||
inner class ScanlatorStore {
|
||||
private val scanlatorMap = mutableMapOf<String, String>()
|
||||
private val totalRetries = 10
|
||||
private var retryCount = 0
|
||||
|
||||
init {
|
||||
update(false)
|
||||
}
|
||||
|
||||
fun getKeyFromValue(value: String): String {
|
||||
update()
|
||||
// Fall back to value as key if endpoint fails
|
||||
return scanlatorMap.keys.firstOrNull {
|
||||
scanlatorMap[it].equals(value)
|
||||
} ?: value
|
||||
}
|
||||
|
||||
fun getValueFromKey(key: String): String {
|
||||
update()
|
||||
// Fallback to key as value if endpoint fails
|
||||
return if (!scanlatorMap[key].isNullOrEmpty()) {
|
||||
scanlatorMap[key].toString()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
fun keys(): MutableSet<String> {
|
||||
update()
|
||||
return scanlatorMap.keys
|
||||
}
|
||||
|
||||
private fun onResponse(response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
retryCount++
|
||||
} else {
|
||||
val json = JSONObject(response.body.string())
|
||||
for (scanId in json.keys()) {
|
||||
scanlatorMap[scanId] = json.getString(scanId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(error: Throwable) {
|
||||
error.printStackTrace()
|
||||
}
|
||||
|
||||
private fun update(blocking: Boolean = true) {
|
||||
if (scanlatorMap.isEmpty() && retryCount < totalRetries) {
|
||||
try {
|
||||
val call = client.newCall(GET(scanlatorCacheUrl, headers))
|
||||
.asObservable()
|
||||
|
||||
if (blocking) {
|
||||
call.toBlocking()
|
||||
.subscribe(::onResponse, ::onError)
|
||||
} else {
|
||||
call.subscribeOn(Schedulers.io())
|
||||
.subscribe(::onResponse, ::onError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Prevent the extension from failing to load
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Things we aren't supporting -----------------
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
throw UnsupportedOperationException("Unused")
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
throw UnsupportedOperationException("Unused")
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
throw UnsupportedOperationException("Unused")
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
throw UnsupportedOperationException("Unused.")
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException("Unused.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SLUG_PREFIX = "slug:"
|
||||
private const val scanlatorPreference = "SCANLATOR_PREFERENCE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.multisrc.guya
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class GuyaGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "guya"
|
||||
|
||||
override val themeClass = "Guya"
|
||||
|
||||
override val baseVersionCode = 5
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Guya", "https://guya.cubari.moe", "en", overrideVersionCode = 18),
|
||||
SingleLang("Danke fürs Lesen", "https://danke.moe", "en", className = "DankeFursLesen"),
|
||||
SingleLang("Hachirumi", "https://hachirumi.com", "en", isNsfw = true),
|
||||
MultiLang("Magical Translators", "https://mahoushoujobu.com", listOf("en", "es", "pl"), overrideVersionCode = 1),
|
||||
)
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
GuyaGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package eu.kanade.tachiyomi.multisrc.guya
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Accepts $baseURL/read/manga/xyz intents
|
||||
*
|
||||
* Added due to requests from various users to allow for opening of titles when given the
|
||||
* Guya URL whilst on mobile.
|
||||
*/
|
||||
class GuyaUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val host = intent?.data?.host
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
if (host != null && pathSegments != null) {
|
||||
val query = fromGuya(pathSegments)
|
||||
|
||||
if (query == null) {
|
||||
Log.e("GuyaUrlActivity", "Unable to parse URI from intent $intent")
|
||||
finish()
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", query)
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("GuyaUrlActivity", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun fromGuya(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 3) {
|
||||
val slug = pathSegments[2]
|
||||
"${Guya.SLUG_PREFIX}$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class HeanCms(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
protected val apiUrl: String = baseUrl.replace("://", "://api."),
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_PAID_CHAPTERS_PREF
|
||||
title = intl.prefShowPaidChapterTitle
|
||||
summaryOn = intl.prefShowPaidChapterSummaryOn
|
||||
summaryOff = intl.prefShowPaidChapterSummaryOff
|
||||
setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit()
|
||||
.putBoolean(SHOW_PAID_CHAPTERS_PREF, newValue as Boolean)
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
protected open val slugStrategy = SlugStrategy.NONE
|
||||
|
||||
protected open val useNewQueryEndpoint = false
|
||||
|
||||
private var seriesSlugMap: Map<String, HeanCmsTitle>? = null
|
||||
|
||||
/**
|
||||
* Custom Json instance to make usage of `encodeDefaults`,
|
||||
* which is not enabled on the injected instance of the app.
|
||||
*/
|
||||
protected val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
protected val intl by lazy { HeanCmsIntl(lang) }
|
||||
|
||||
protected open val coverPath: String = "cover/"
|
||||
|
||||
protected open val mangaSubDirectory: String = "series"
|
||||
|
||||
protected open val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.US)
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("Origin", baseUrl)
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
if (useNewQueryEndpoint) {
|
||||
return newEndpointPopularMangaRequest(page)
|
||||
}
|
||||
|
||||
val payloadObj = HeanCmsQuerySearchPayloadDto(
|
||||
page = page,
|
||||
order = "desc",
|
||||
orderBy = "total_views",
|
||||
status = "All",
|
||||
type = "Comic",
|
||||
)
|
||||
|
||||
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Content-Type", payload.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
|
||||
}
|
||||
|
||||
protected fun newEndpointPopularMangaRequest(page: Int): Request {
|
||||
val url = "$apiUrl/query".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("query_string", "")
|
||||
.addQueryParameter("series_status", "All")
|
||||
.addQueryParameter("order", "desc")
|
||||
.addQueryParameter("orderBy", "total_views")
|
||||
.addQueryParameter("series_type", "Comic")
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("perPage", "12")
|
||||
.addQueryParameter("tags_ids", "[]")
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val json = response.body.string()
|
||||
|
||||
if (json.startsWith("{")) {
|
||||
val result = json.parseAs<HeanCmsQuerySearchDto>()
|
||||
val mangaList = result.data.map {
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
|
||||
}
|
||||
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
|
||||
}
|
||||
|
||||
fetchAllTitles()
|
||||
|
||||
return MangasPage(mangaList, result.meta?.hasNextPage ?: false)
|
||||
}
|
||||
|
||||
val mangaList = json.parseAs<List<HeanCmsSeriesDto>>()
|
||||
.map {
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
|
||||
}
|
||||
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
|
||||
}
|
||||
|
||||
fetchAllTitles()
|
||||
|
||||
return MangasPage(mangaList, hasNextPage = false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
if (useNewQueryEndpoint) {
|
||||
return newEndpointLatestUpdatesRequest(page)
|
||||
}
|
||||
|
||||
val payloadObj = HeanCmsQuerySearchPayloadDto(
|
||||
page = page,
|
||||
order = "desc",
|
||||
orderBy = "latest",
|
||||
status = "All",
|
||||
type = "Comic",
|
||||
)
|
||||
|
||||
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Content-Type", payload.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
|
||||
}
|
||||
|
||||
protected fun newEndpointLatestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/query".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("query_string", "")
|
||||
.addQueryParameter("series_status", "All")
|
||||
.addQueryParameter("order", "desc")
|
||||
.addQueryParameter("orderBy", "latest")
|
||||
.addQueryParameter("series_type", "Comic")
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("perPage", "12")
|
||||
.addQueryParameter("tags_ids", "[]")
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (!query.startsWith(SEARCH_PREFIX)) {
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
val slug = query.substringAfter(SEARCH_PREFIX)
|
||||
val manga = SManga.create().apply {
|
||||
url = if (slugStrategy != SlugStrategy.NONE) {
|
||||
val mangaId = getIdBySlug(slug)
|
||||
"/$mangaSubDirectory/${slug.toPermSlugIfNeeded()}#$mangaId"
|
||||
} else {
|
||||
"/$mangaSubDirectory/$slug"
|
||||
}
|
||||
}
|
||||
|
||||
return fetchMangaDetails(manga).map { MangasPage(listOf(it), false) }
|
||||
}
|
||||
|
||||
private fun getIdBySlug(slug: String): Int {
|
||||
val result = runCatching {
|
||||
val response = client.newCall(GET("$apiUrl/series/$slug", headers)).execute()
|
||||
val json = response.body.string()
|
||||
|
||||
val seriesDetail = json.parseAs<HeanCmsSeriesDto>()
|
||||
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { it[seriesDetail.slug.toPermSlugIfNeeded()] = seriesDetail.slug }
|
||||
|
||||
seriesDetail.id
|
||||
}
|
||||
return result.getOrNull() ?: throw Exception(intl.idNotFoundError + slug)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (useNewQueryEndpoint) {
|
||||
return newEndpointSearchMangaRequest(page, query, filters)
|
||||
}
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
val searchPayloadObj = HeanCmsSearchPayloadDto(query)
|
||||
val searchPayload = json.encodeToString(searchPayloadObj)
|
||||
.toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Content-Type", searchPayload.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/series/search", apiHeaders, searchPayload)
|
||||
}
|
||||
|
||||
val sortByFilter = filters.firstInstanceOrNull<SortByFilter>()
|
||||
|
||||
val payloadObj = HeanCmsQuerySearchPayloadDto(
|
||||
page = page,
|
||||
order = if (sortByFilter?.state?.ascending == true) "asc" else "desc",
|
||||
orderBy = sortByFilter?.selected ?: "total_views",
|
||||
status = filters.firstInstanceOrNull<StatusFilter>()?.selected?.value ?: "Ongoing",
|
||||
type = "Comic",
|
||||
tagIds = filters.firstInstanceOrNull<GenreFilter>()?.state
|
||||
?.filter(Genre::state)
|
||||
?.map(Genre::id)
|
||||
.orEmpty(),
|
||||
)
|
||||
|
||||
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Content-Type", payload.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
|
||||
}
|
||||
|
||||
protected fun newEndpointSearchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val sortByFilter = filters.firstInstanceOrNull<SortByFilter>()
|
||||
val statusFilter = filters.firstInstanceOrNull<StatusFilter>()
|
||||
|
||||
val tagIds = filters.firstInstanceOrNull<GenreFilter>()?.state.orEmpty()
|
||||
.filter(Genre::state)
|
||||
.map(Genre::id)
|
||||
.joinToString(",", prefix = "[", postfix = "]")
|
||||
|
||||
val url = "$apiUrl/query".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("query_string", query)
|
||||
.addQueryParameter("series_status", statusFilter?.selected?.value ?: "All")
|
||||
.addQueryParameter("order", if (sortByFilter?.state?.ascending == true) "asc" else "desc")
|
||||
.addQueryParameter("orderBy", sortByFilter?.selected ?: "total_views")
|
||||
.addQueryParameter("series_type", "Comic")
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("perPage", "12")
|
||||
.addQueryParameter("tags_ids", tagIds)
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val json = response.body.string()
|
||||
|
||||
if (response.request.url.pathSegments.last() == "search") {
|
||||
fetchAllTitles()
|
||||
|
||||
val result = json.parseAs<List<HeanCmsSearchDto>>()
|
||||
val mangaList = result
|
||||
.filter { it.type == "Comic" }
|
||||
.map {
|
||||
it.slug = it.slug.toPermSlugIfNeeded()
|
||||
it.toSManga(apiUrl, coverPath, mangaSubDirectory, seriesSlugMap.orEmpty(), slugStrategy)
|
||||
}
|
||||
|
||||
return MangasPage(mangaList, false)
|
||||
}
|
||||
|
||||
if (json.startsWith("{")) {
|
||||
val result = json.parseAs<HeanCmsQuerySearchDto>()
|
||||
val mangaList = result.data.map {
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
|
||||
}
|
||||
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
|
||||
}
|
||||
|
||||
fetchAllTitles()
|
||||
|
||||
return MangasPage(mangaList, result.meta?.hasNextPage ?: false)
|
||||
}
|
||||
|
||||
val mangaList = json.parseAs<List<HeanCmsSeriesDto>>()
|
||||
.map {
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
|
||||
}
|
||||
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
|
||||
}
|
||||
|
||||
fetchAllTitles()
|
||||
|
||||
return MangasPage(mangaList, hasNextPage = false)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
val seriesSlug = manga.url
|
||||
.substringAfterLast("/")
|
||||
.substringBefore("#")
|
||||
.toPermSlugIfNeeded()
|
||||
|
||||
val currentSlug = if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap[seriesSlug] ?: seriesSlug
|
||||
} else {
|
||||
seriesSlug
|
||||
}
|
||||
|
||||
return "$baseUrl/$mangaSubDirectory/$currentSlug"
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
if (slugStrategy != SlugStrategy.NONE && (manga.url.contains(TIMESTAMP_REGEX))) {
|
||||
throw Exception(intl.urlChangedError(name))
|
||||
}
|
||||
|
||||
if (slugStrategy == SlugStrategy.ID && !manga.url.contains("#")) {
|
||||
throw Exception(intl.urlChangedError(name))
|
||||
}
|
||||
|
||||
val seriesSlug = manga.url
|
||||
.substringAfterLast("/")
|
||||
.substringBefore("#")
|
||||
.toPermSlugIfNeeded()
|
||||
|
||||
val seriesId = manga.url.substringAfterLast("#")
|
||||
|
||||
fetchAllTitles()
|
||||
|
||||
val seriesDetails = seriesSlugMap?.get(seriesSlug)
|
||||
val currentSlug = seriesDetails?.slug ?: seriesSlug
|
||||
val currentStatus = seriesDetails?.status ?: manga.status
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.build()
|
||||
|
||||
return if (slugStrategy == SlugStrategy.ID) {
|
||||
GET("$apiUrl/series/id/$seriesId", apiHeaders)
|
||||
} else {
|
||||
GET("$apiUrl/series/$currentSlug#$currentStatus", apiHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val mangaStatus = response.request.url.fragment?.toIntOrNull() ?: SManga.UNKNOWN
|
||||
|
||||
val result = runCatching { response.parseAs<HeanCmsSeriesDto>() }
|
||||
|
||||
val seriesResult = result.getOrNull() ?: throw Exception(intl.urlChangedError(name))
|
||||
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { it[seriesResult.slug.toPermSlugIfNeeded()] = seriesResult.slug }
|
||||
}
|
||||
|
||||
val seriesDetails = seriesResult.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
|
||||
|
||||
return seriesDetails.apply {
|
||||
status = status.takeUnless { it == SManga.UNKNOWN }
|
||||
?: mangaStatus
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<HeanCmsSeriesDto>()
|
||||
|
||||
if (slugStrategy == SlugStrategy.ID) {
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { it[result.slug.toPermSlugIfNeeded()] = result.slug }
|
||||
}
|
||||
|
||||
val currentTimestamp = System.currentTimeMillis()
|
||||
|
||||
val showPaidChapters = preferences.showPaidChapters
|
||||
|
||||
if (useNewQueryEndpoint) {
|
||||
return result.seasons.orEmpty()
|
||||
.flatMap { it.chapters.orEmpty() }
|
||||
.filter { it.price == 0 || showPaidChapters }
|
||||
.map { it.toSChapter(result.slug, mangaSubDirectory, dateFormat, slugStrategy) }
|
||||
.filter { it.date_upload <= currentTimestamp }
|
||||
}
|
||||
|
||||
return result.chapters.orEmpty()
|
||||
.filter { it.price == 0 || showPaidChapters }
|
||||
.map { it.toSChapter(result.slug, mangaSubDirectory, dateFormat, slugStrategy) }
|
||||
.filter { it.date_upload <= currentTimestamp }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
if (slugStrategy == SlugStrategy.NONE) return baseUrl + chapter.url
|
||||
|
||||
val seriesSlug = chapter.url
|
||||
.substringAfter("/$mangaSubDirectory/")
|
||||
.substringBefore("/")
|
||||
.toPermSlugIfNeeded()
|
||||
|
||||
val currentSlug = preferences.slugMap[seriesSlug] ?: seriesSlug
|
||||
val chapterUrl = chapter.url.replaceFirst(seriesSlug, currentSlug)
|
||||
|
||||
return baseUrl + chapterUrl
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (useNewQueryEndpoint) {
|
||||
if (slugStrategy != SlugStrategy.NONE) {
|
||||
val seriesPermSlug = chapter.url.substringAfter("/$mangaSubDirectory/").substringBefore("/")
|
||||
val seriesSlug = preferences.slugMap[seriesPermSlug] ?: seriesPermSlug
|
||||
val chapterUrl = chapter.url.replaceFirst(seriesPermSlug, seriesSlug)
|
||||
return GET(baseUrl + chapterUrl, headers)
|
||||
}
|
||||
return GET(baseUrl + chapter.url, headers)
|
||||
}
|
||||
|
||||
val chapterId = chapter.url.substringAfterLast("#").substringBefore("-paid")
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.build()
|
||||
|
||||
return GET("$apiUrl/series/chapter/$chapterId", apiHeaders)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
if (useNewQueryEndpoint) {
|
||||
val paidChapter = response.request.url.fragment?.contains("-paid")
|
||||
|
||||
val document = response.asJsoup()
|
||||
|
||||
val images = document.selectFirst("div.min-h-screen > div.container > p.items-center")
|
||||
|
||||
if (images == null && paidChapter == true) {
|
||||
throw IOException(intl.paidChapterError)
|
||||
}
|
||||
|
||||
return images?.select("img").orEmpty().mapIndexed { i, img ->
|
||||
val imageUrl = if (img.hasClass("lazy")) img.absUrl("data-src") else img.absUrl("src")
|
||||
Page(i, "", imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val images = response.parseAs<HeanCmsReaderDto>().content?.images.orEmpty()
|
||||
val paidChapter = response.request.url.fragment?.contains("-paid")
|
||||
|
||||
if (images.isEmpty() && paidChapter == true) {
|
||||
throw IOException(intl.paidChapterError)
|
||||
}
|
||||
|
||||
return images.filterNot { imageUrl ->
|
||||
// Their image server returns HTTP 403 for hidden files that starts
|
||||
// with a dot in the file name. To avoid download errors, these are removed.
|
||||
imageUrl
|
||||
.removeSuffix("/")
|
||||
.substringAfterLast("/")
|
||||
.startsWith(".")
|
||||
}
|
||||
.mapIndexed { i, url ->
|
||||
Page(i, imageUrl = if (url.startsWith("http")) url else "$apiUrl/$url")
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imageHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_IMAGE)
|
||||
.build()
|
||||
|
||||
return GET(page.imageUrl!!, imageHeaders)
|
||||
}
|
||||
|
||||
protected open fun fetchAllTitles() {
|
||||
if (!seriesSlugMap.isNullOrEmpty() || slugStrategy != SlugStrategy.FETCH_ALL) {
|
||||
return
|
||||
}
|
||||
|
||||
val result = runCatching {
|
||||
var hasNextPage = true
|
||||
var page = 1
|
||||
val tempMap = mutableMapOf<String, HeanCmsTitle>()
|
||||
|
||||
while (hasNextPage) {
|
||||
val response = client.newCall(allTitlesRequest(page)).execute()
|
||||
val json = response.body.string()
|
||||
|
||||
if (json.startsWith("{")) {
|
||||
val result = json.parseAs<HeanCmsQuerySearchDto>()
|
||||
tempMap.putAll(parseAllTitles(result.data))
|
||||
hasNextPage = result.meta?.hasNextPage ?: false
|
||||
page++
|
||||
} else {
|
||||
val result = json.parseAs<List<HeanCmsSeriesDto>>()
|
||||
tempMap.putAll(parseAllTitles(result))
|
||||
hasNextPage = false
|
||||
}
|
||||
}
|
||||
|
||||
tempMap.toMap()
|
||||
}
|
||||
|
||||
seriesSlugMap = result.getOrNull()
|
||||
preferences.slugMap = preferences.slugMap.toMutableMap()
|
||||
.also { it.putAll(seriesSlugMap.orEmpty().mapValues { (_, v) -> v.slug }) }
|
||||
}
|
||||
|
||||
protected open fun allTitlesRequest(page: Int): Request {
|
||||
if (useNewQueryEndpoint) {
|
||||
val url = "$apiUrl/query".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("series_type", "Comic")
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("perPage", PER_PAGE_MANGA_TITLES.toString())
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
val payloadObj = HeanCmsQuerySearchPayloadDto(
|
||||
page = page,
|
||||
order = "desc",
|
||||
orderBy = "total_views",
|
||||
type = "Comic",
|
||||
)
|
||||
|
||||
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Content-Type", payload.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
|
||||
}
|
||||
|
||||
protected open fun parseAllTitles(result: List<HeanCmsSeriesDto>): Map<String, HeanCmsTitle> {
|
||||
return result
|
||||
.filter { it.type == "Comic" }
|
||||
.associateBy(
|
||||
keySelector = { it.slug.replace(TIMESTAMP_REGEX, "") },
|
||||
valueTransform = {
|
||||
HeanCmsTitle(
|
||||
slug = it.slug,
|
||||
thumbnailFileName = it.thumbnail,
|
||||
status = it.status?.toStatus() ?: SManga.UNKNOWN,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to store the current slugs for sources that change it periodically and for the
|
||||
* search that doesn't return the thumbnail URLs.
|
||||
*/
|
||||
data class HeanCmsTitle(val slug: String, val thumbnailFileName: String, val status: Int)
|
||||
|
||||
/**
|
||||
* Used to specify the strategy to use when fetching the slug for a manga.
|
||||
* This is needed because some sources change the slug periodically.
|
||||
* [NONE]: Use series_slug without changes.
|
||||
* [ID]: Use series_id to fetch the slug from the API.
|
||||
* IMPORTANT: [ID] is only available in the new query endpoint.
|
||||
* [FETCH_ALL]: Convert the slug to a permanent slug by removing the timestamp.
|
||||
* At extension start, all the slugs are fetched and stored in a map.
|
||||
*/
|
||||
enum class SlugStrategy {
|
||||
NONE, ID, FETCH_ALL
|
||||
}
|
||||
|
||||
private fun String.toPermSlugIfNeeded(): String {
|
||||
return if (slugStrategy != SlugStrategy.NONE) {
|
||||
this.replace(TIMESTAMP_REGEX, "")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getStatusList(): List<Status> = listOf(
|
||||
Status(intl.statusAll, "All"),
|
||||
Status(intl.statusOngoing, "Ongoing"),
|
||||
Status(intl.statusOnHiatus, "Hiatus"),
|
||||
Status(intl.statusDropped, "Dropped"),
|
||||
)
|
||||
|
||||
protected open fun getSortProperties(): List<SortProperty> = listOf(
|
||||
SortProperty(intl.sortByTitle, "title"),
|
||||
SortProperty(intl.sortByViews, "total_views"),
|
||||
SortProperty(intl.sortByLatest, "latest"),
|
||||
SortProperty(intl.sortByCreatedAt, "created_at"),
|
||||
)
|
||||
|
||||
protected open fun getGenreList(): List<Genre> = emptyList()
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val genres = getGenreList()
|
||||
|
||||
val filters = listOfNotNull(
|
||||
Filter.Header(intl.filterWarning),
|
||||
StatusFilter(intl.statusFilterTitle, getStatusList()),
|
||||
SortByFilter(intl.sortByFilterTitle, getSortProperties()),
|
||||
GenreFilter(intl.genreFilterTitle, genres).takeIf { genres.isNotEmpty() },
|
||||
)
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): T = use {
|
||||
it.body.string().parseAs()
|
||||
}
|
||||
|
||||
protected inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
protected inline fun <reified R> List<*>.firstInstanceOrNull(): R? =
|
||||
filterIsInstance<R>().firstOrNull()
|
||||
|
||||
protected var SharedPreferences.slugMap: MutableMap<String, String>
|
||||
get() {
|
||||
val jsonMap = getString(PREF_URL_MAP_SLUG, "{}")!!
|
||||
val slugMap = runCatching { json.decodeFromString<Map<String, String>>(jsonMap) }
|
||||
return slugMap.getOrNull()?.toMutableMap() ?: mutableMapOf()
|
||||
}
|
||||
set(newSlugMap) {
|
||||
edit()
|
||||
.putString(PREF_URL_MAP_SLUG, json.encodeToString(newSlugMap))
|
||||
.apply()
|
||||
}
|
||||
|
||||
private val SharedPreferences.showPaidChapters: Boolean
|
||||
get() = getBoolean(SHOW_PAID_CHAPTERS_PREF, SHOW_PAID_CHAPTERS_DEFAULT)
|
||||
|
||||
companion object {
|
||||
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||
private const val ACCEPT_JSON = "application/json, text/plain, */*"
|
||||
|
||||
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
|
||||
val TIMESTAMP_REGEX = """-\d{13}$""".toRegex()
|
||||
|
||||
private const val PER_PAGE_MANGA_TITLES = 10000
|
||||
|
||||
const val SEARCH_PREFIX = "slug:"
|
||||
|
||||
private const val PREF_URL_MAP_SLUG = "pref_url_map"
|
||||
|
||||
private const val SHOW_PAID_CHAPTERS_PREF = "pref_show_paid_chap"
|
||||
private const val SHOW_PAID_CHAPTERS_DEFAULT = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.heancms.HeanCms.SlugStrategy
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsQuerySearchDto(
|
||||
val data: List<HeanCmsSeriesDto> = emptyList(),
|
||||
val meta: HeanCmsQuerySearchMetaDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsQuerySearchMetaDto(
|
||||
@SerialName("current_page") val currentPage: Int,
|
||||
@SerialName("last_page") val lastPage: Int,
|
||||
) {
|
||||
|
||||
val hasNextPage: Boolean
|
||||
get() = currentPage < lastPage
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsSearchDto(
|
||||
val description: String? = null,
|
||||
@SerialName("series_slug") var slug: String,
|
||||
@SerialName("series_type") val type: String,
|
||||
val title: String,
|
||||
val thumbnail: String? = null,
|
||||
) {
|
||||
|
||||
fun toSManga(
|
||||
apiUrl: String,
|
||||
coverPath: String,
|
||||
mangaSubDirectory: String,
|
||||
slugMap: Map<String, HeanCms.HeanCmsTitle>,
|
||||
slugStrategy: SlugStrategy,
|
||||
): SManga = SManga.create().apply {
|
||||
val slugOnly = slug.toPermSlugIfNeeded(slugStrategy)
|
||||
val thumbnailFileName = slugMap[slugOnly]?.thumbnailFileName
|
||||
title = this@HeanCmsSearchDto.title
|
||||
thumbnail_url = thumbnail?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
|
||||
?: thumbnailFileName?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
|
||||
url = "/$mangaSubDirectory/$slugOnly"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsSeriesDto(
|
||||
val id: Int,
|
||||
@SerialName("series_slug") val slug: String,
|
||||
@SerialName("series_type") val type: String = "Comic",
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val studio: String? = null,
|
||||
val status: String? = null,
|
||||
val thumbnail: String,
|
||||
val title: String,
|
||||
val tags: List<HeanCmsTagDto>? = emptyList(),
|
||||
val chapters: List<HeanCmsChapterDto>? = emptyList(),
|
||||
val seasons: List<HeanCmsSeasonsDto>? = emptyList(),
|
||||
) {
|
||||
|
||||
fun toSManga(
|
||||
apiUrl: String,
|
||||
coverPath: String,
|
||||
mangaSubDirectory: String,
|
||||
slugStrategy: SlugStrategy,
|
||||
): SManga = SManga.create().apply {
|
||||
val descriptionBody = this@HeanCmsSeriesDto.description?.let(Jsoup::parseBodyFragment)
|
||||
val slugOnly = slug.toPermSlugIfNeeded(slugStrategy)
|
||||
|
||||
title = this@HeanCmsSeriesDto.title
|
||||
author = this@HeanCmsSeriesDto.author?.trim()
|
||||
artist = this@HeanCmsSeriesDto.studio?.trim()
|
||||
description = descriptionBody?.select("p")
|
||||
?.joinToString("\n\n") { it.text() }
|
||||
?.ifEmpty { descriptionBody.text().replace("\n", "\n\n") }
|
||||
genre = tags.orEmpty()
|
||||
.sortedBy(HeanCmsTagDto::name)
|
||||
.joinToString { it.name }
|
||||
thumbnail_url = thumbnail.ifEmpty { null }
|
||||
?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
|
||||
status = this@HeanCmsSeriesDto.status?.toStatus() ?: SManga.UNKNOWN
|
||||
url = if (slugStrategy != SlugStrategy.NONE) {
|
||||
"/$mangaSubDirectory/$slugOnly#$id"
|
||||
} else {
|
||||
"/$mangaSubDirectory/$slug"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsSeasonsDto(
|
||||
val index: Int,
|
||||
val chapters: List<HeanCmsChapterDto>? = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsTagDto(val name: String)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsChapterDto(
|
||||
val id: Int,
|
||||
@SerialName("chapter_name") val name: String,
|
||||
@SerialName("chapter_slug") val slug: String,
|
||||
val index: String,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
val price: Int? = null,
|
||||
) {
|
||||
fun toSChapter(
|
||||
seriesSlug: String,
|
||||
mangaSubDirectory: String,
|
||||
dateFormat: SimpleDateFormat,
|
||||
slugStrategy: SlugStrategy,
|
||||
): SChapter = SChapter.create().apply {
|
||||
val seriesSlugOnly = seriesSlug.toPermSlugIfNeeded(slugStrategy)
|
||||
name = this@HeanCmsChapterDto.name.trim()
|
||||
|
||||
if (price != 0) {
|
||||
name += " \uD83D\uDD12"
|
||||
}
|
||||
|
||||
date_upload = runCatching { dateFormat.parse(createdAt)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
|
||||
val paidStatus = if (price != 0 && price != null) "-paid" else ""
|
||||
|
||||
url = "/$mangaSubDirectory/$seriesSlugOnly/$slug#$id$paidStatus"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsReaderDto(
|
||||
val content: HeanCmsReaderContentDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsReaderContentDto(
|
||||
val images: List<String>? = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsQuerySearchPayloadDto(
|
||||
val order: String,
|
||||
val page: Int,
|
||||
@SerialName("order_by") val orderBy: String,
|
||||
@SerialName("series_status") val status: String? = null,
|
||||
@SerialName("series_type") val type: String,
|
||||
@SerialName("tags_ids") val tagIds: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeanCmsSearchPayloadDto(val term: String)
|
||||
|
||||
private fun String.toAbsoluteThumbnailUrl(apiUrl: String, coverPath: String): String {
|
||||
return if (startsWith("https://")) this else "$apiUrl/$coverPath$this"
|
||||
}
|
||||
|
||||
private fun String.toPermSlugIfNeeded(slugStrategy: SlugStrategy): String {
|
||||
return if (slugStrategy != SlugStrategy.NONE) {
|
||||
this.replace(HeanCms.TIMESTAMP_REGEX, "")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toStatus(): Int = when (this) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Hiatus" -> SManga.ON_HIATUS
|
||||
"Dropped" -> SManga.CANCELLED
|
||||
"Completed", "Finished" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
class Genre(title: String, val id: Int) : Filter.CheckBox(title)
|
||||
|
||||
class GenreFilter(title: String, genres: List<Genre>) : Filter.Group<Genre>(title, genres)
|
||||
|
||||
open class EnhancedSelect<T>(name: String, values: Array<T>) : Filter.Select<T>(name, values) {
|
||||
val selected: T
|
||||
get() = values[state]
|
||||
}
|
||||
|
||||
data class Status(val name: String, val value: String) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
class StatusFilter(title: String, statuses: List<Status>) :
|
||||
EnhancedSelect<Status>(title, statuses.toTypedArray())
|
||||
|
||||
data class SortProperty(val name: String, val value: String) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
class SortByFilter(title: String, private val sortProperties: List<SortProperty>) : Filter.Sort(
|
||||
title,
|
||||
sortProperties.map { it.name }.toTypedArray(),
|
||||
Selection(1, ascending = false),
|
||||
) {
|
||||
val selected: String
|
||||
get() = sortProperties[state!!.index].value
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class HeanCmsGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "heancms"
|
||||
|
||||
override val themeClass = "HeanCms"
|
||||
|
||||
override val baseVersionCode: Int = 20
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Omega Scans", "https://omegascans.org", "en", isNsfw = true, overrideVersionCode = 18),
|
||||
SingleLang("Perf Scan", "https://perf-scan.fr", "fr"),
|
||||
SingleLang("Temple Scan", "https://templescan.net", "en", isNsfw = true, overrideVersionCode = 16),
|
||||
SingleLang("YugenMangas", "https://yugenmangas.net", "es", isNsfw = true, overrideVersionCode = 9),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
HeanCmsGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
class HeanCmsIntl(lang: String) {
|
||||
|
||||
val availableLang: String = if (lang in AVAILABLE_LANGS) lang else ENGLISH
|
||||
|
||||
val genreFilterTitle: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Gêneros"
|
||||
SPANISH -> "Géneros"
|
||||
else -> "Genres"
|
||||
}
|
||||
|
||||
val statusFilterTitle: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Estado"
|
||||
SPANISH -> "Estado"
|
||||
else -> "Status"
|
||||
}
|
||||
|
||||
val statusAll: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Todos"
|
||||
SPANISH -> "Todos"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
val statusOngoing: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Em andamento"
|
||||
SPANISH -> "En curso"
|
||||
else -> "Ongoing"
|
||||
}
|
||||
|
||||
val statusOnHiatus: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Em hiato"
|
||||
SPANISH -> "En hiatus"
|
||||
else -> "On Hiatus"
|
||||
}
|
||||
|
||||
val statusDropped: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Cancelada"
|
||||
SPANISH -> "Abandonada"
|
||||
else -> "Dropped"
|
||||
}
|
||||
|
||||
val sortByFilterTitle: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Ordenar por"
|
||||
SPANISH -> "Ordenar por"
|
||||
else -> "Sort by"
|
||||
}
|
||||
|
||||
val sortByTitle: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Título"
|
||||
SPANISH -> "Titulo"
|
||||
else -> "Title"
|
||||
}
|
||||
|
||||
val sortByViews: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Visualizações"
|
||||
SPANISH -> "Número de vistas"
|
||||
else -> "Views"
|
||||
}
|
||||
|
||||
val sortByLatest: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Recentes"
|
||||
SPANISH -> "Recientes"
|
||||
else -> "Latest"
|
||||
}
|
||||
|
||||
val sortByCreatedAt: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Data de criação"
|
||||
SPANISH -> "Fecha de creación"
|
||||
else -> "Created at"
|
||||
}
|
||||
|
||||
val filterWarning: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Os filtros serão ignorados se a busca não estiver vazia."
|
||||
SPANISH -> "Los filtros serán ignorados si la búsqueda no está vacía."
|
||||
else -> "Filters will be ignored if the search is not empty."
|
||||
}
|
||||
|
||||
val prefShowPaidChapterTitle: String = when (availableLang) {
|
||||
SPANISH -> "Mostrar capítulos de pago"
|
||||
else -> "Display paid chapters"
|
||||
}
|
||||
|
||||
val prefShowPaidChapterSummaryOn: String = when (availableLang) {
|
||||
SPANISH -> "Se mostrarán capítulos de pago. Deberá iniciar sesión"
|
||||
else -> "Paid chapters will appear. A login might be needed!"
|
||||
}
|
||||
|
||||
val prefShowPaidChapterSummaryOff: String = when (availableLang) {
|
||||
SPANISH -> "Solo se mostrarán los capítulos gratuitos"
|
||||
else -> "Only free chapters will be displayed."
|
||||
}
|
||||
|
||||
val paidChapterError: String = when (availableLang) {
|
||||
SPANISH -> "Capítulo no disponible. Debe iniciar sesión en Webview y tener el capítulo comprado."
|
||||
else -> "Paid chapter unavailable.\nA login/purchase might be needed (using webview)."
|
||||
}
|
||||
|
||||
fun urlChangedError(sourceName: String): String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE ->
|
||||
"A URL da série mudou. Migre de $sourceName " +
|
||||
"para $sourceName para atualizar a URL."
|
||||
SPANISH ->
|
||||
"La URL de la serie ha cambiado. Migre de $sourceName a " +
|
||||
"$sourceName para actualizar la URL."
|
||||
else ->
|
||||
"The URL of the series has changed. Migrate from $sourceName " +
|
||||
"to $sourceName to update the URL."
|
||||
}
|
||||
|
||||
val idNotFoundError: String = when (availableLang) {
|
||||
BRAZILIAN_PORTUGUESE -> "Falha ao obter o ID do slug: "
|
||||
SPANISH -> "No se pudo encontrar el ID para: "
|
||||
else -> "Failed to get the ID for slug: "
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BRAZILIAN_PORTUGUESE = "pt-BR"
|
||||
const val ENGLISH = "en"
|
||||
const val SPANISH = "es"
|
||||
|
||||
val AVAILABLE_LANGS = arrayOf(BRAZILIAN_PORTUGUESE, ENGLISH, SPANISH)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.kanade.tachiyomi.multisrc.heancms
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class HeanCmsUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
if (pathSegments != null && pathSegments.size >= 2) {
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", createQuery(pathSegments))
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("HeanCmsUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("HeanCmsUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun createQuery(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 2) {
|
||||
val slug = pathSegments[1]
|
||||
"${HeanCms.SEARCH_PREFIX}$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package eu.kanade.tachiyomi.multisrc.hentaihand
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
abstract class HentaiHand(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val chapters: Boolean,
|
||||
private val hhLangId: List<Int> = emptyList(),
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private fun slugToUrl(json: JsonObject) = json["slug"]!!.jsonPrimitive.content.prependIndent("/en/comic/")
|
||||
|
||||
private fun jsonArrayToString(arrayKey: String, obj: JsonObject): String? {
|
||||
val array = obj[arrayKey]!!.jsonArray
|
||||
if (array.isEmpty()) return null
|
||||
return array.joinToString(", ") {
|
||||
it.jsonObject["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val jsonResponse = json.parseToJsonElement(response.body.string())
|
||||
val mangaList = jsonResponse.jsonObject["data"]!!.jsonArray.map {
|
||||
val obj = it.jsonObject
|
||||
SManga.create().apply {
|
||||
url = slugToUrl(obj)
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
val hasNextPage = jsonResponse.jsonObject["next_page_url"]!!.jsonPrimitive.content.isNotEmpty()
|
||||
return MangasPage(mangaList, hasNextPage)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$baseUrl/api/comics".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("sort", "popularity")
|
||||
.addQueryParameter("order", "desc")
|
||||
.addQueryParameter("duration", "all")
|
||||
hhLangId.forEachIndexed { index, it ->
|
||||
url.addQueryParameter("languages[${-index - 1}]", it.toString())
|
||||
}
|
||||
// if (altLangId != null) url.addQueryParameter("languages", altLangId.toString())
|
||||
return GET(url.toString())
|
||||
}
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$baseUrl/api/comics".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("sort", "uploaded_at")
|
||||
.addQueryParameter("order", "desc")
|
||||
.addQueryParameter("duration", "all")
|
||||
hhLangId.forEachIndexed { index, it ->
|
||||
url.addQueryParameter("languages[${-index - 1}]", it.toString())
|
||||
}
|
||||
return GET(url.toString())
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
private fun lookupFilterId(query: String, uri: String): Int? {
|
||||
// filter query needs to be resolved to an ID
|
||||
return client.newCall(GET("$baseUrl/api/$uri?q=$query"))
|
||||
.asObservableSuccess()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { response ->
|
||||
// Returns the first matched id, or null if there are no results
|
||||
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map {
|
||||
it.jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
if (idList.isEmpty()) {
|
||||
return@map null
|
||||
} else {
|
||||
idList.first().toInt()
|
||||
}
|
||||
}.toBlocking().first()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/api/comics".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter("q", query)
|
||||
|
||||
hhLangId.forEachIndexed { index, it ->
|
||||
url.addQueryParameter("languages[${-index - 1}]", it.toString())
|
||||
}
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is SortFilter -> url.addQueryParameter("sort", getSortPairs()[filter.state].second)
|
||||
is OrderFilter -> url.addQueryParameter("order", getOrderPairs()[filter.state].second)
|
||||
is DurationFilter -> url.addQueryParameter("duration", getDurationPairs()[filter.state].second)
|
||||
is AttributesGroupFilter -> filter.state.forEach {
|
||||
if (it.state) url.addQueryParameter("attributes", it.value)
|
||||
}
|
||||
is StatusGroupFilter -> filter.state.forEach {
|
||||
if (it.state) url.addQueryParameter("statuses", it.value)
|
||||
}
|
||||
is LookupFilter -> {
|
||||
filter.state.split(",").map { it.trim() }.filter { it.isNotBlank() }.map {
|
||||
lookupFilterId(it, filter.uri) ?: throw Exception("No ${filter.singularName} \"$it\" was found")
|
||||
}.forEachIndexed { index, it ->
|
||||
if (!(filter.uri == "languages" && hhLangId.contains(it))) {
|
||||
url.addQueryParameter(filter.uri + "[$index]", it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString())
|
||||
}
|
||||
|
||||
// Details
|
||||
|
||||
private fun mangaDetailsApiRequest(manga: SManga): Request {
|
||||
val slug = manga.url.removePrefix("/en/comic/")
|
||||
return GET("$baseUrl/api/comics/$slug")
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsApiRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { mangaDetailsParse(it).apply { initialized = true } }
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val obj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
return SManga.create().apply {
|
||||
url = slugToUrl(obj)
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
|
||||
artist = jsonArrayToString("artists", obj)
|
||||
author = jsonArrayToString("authors", obj) ?: artist
|
||||
genre = listOfNotNull(jsonArrayToString("tags", obj), jsonArrayToString("relationships", obj)).joinToString(", ")
|
||||
status = when (obj["status"]!!.jsonPrimitive.content) {
|
||||
"complete" -> SManga.COMPLETED
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"onhold" -> SManga.ONGOING
|
||||
"canceled" -> SManga.COMPLETED
|
||||
else -> SManga.COMPLETED
|
||||
}
|
||||
|
||||
description = listOf(
|
||||
Pair("Alternative Title", obj["alternative_title"]!!.jsonPrimitive.content),
|
||||
Pair("Groups", jsonArrayToString("groups", obj)),
|
||||
Pair("Description", obj["description"]!!.jsonPrimitive.content),
|
||||
Pair("Pages", obj["pages"]!!.jsonPrimitive.content),
|
||||
Pair("Category", try { obj["category"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
|
||||
Pair("Language", try { obj["language"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
|
||||
Pair("Parodies", jsonArrayToString("parodies", obj)),
|
||||
Pair("Characters", jsonArrayToString("characters", obj)),
|
||||
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
private fun chapterListApiRequest(manga: SManga): Request {
|
||||
val slug = manga.url.removePrefix("/en/comic/")
|
||||
return if (chapters) {
|
||||
GET("$baseUrl/api/comics/$slug/chapters")
|
||||
} else {
|
||||
GET("$baseUrl/api/comics/$slug")
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val slug = response.request.url.toString().substringAfter("/api/comics/").removeSuffix("/chapters")
|
||||
return if (chapters) {
|
||||
val array = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
array.map {
|
||||
SChapter.create().apply {
|
||||
url = "$slug/${it.jsonObject["slug"]!!.jsonPrimitive.content}"
|
||||
name = it.jsonObject["name"]!!.jsonPrimitive.content
|
||||
val date = it.jsonObject["added_at"]!!.jsonPrimitive.content
|
||||
date_upload = if (date.contains("day")) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, date.filter { it.isDigit() }.toInt() * -1)
|
||||
}.timeInMillis
|
||||
} else {
|
||||
DATE_FORMAT.parse(it.jsonObject["added_at"]!!.jsonPrimitive.content)?.time ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val obj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = obj["slug"]!!.jsonPrimitive.content
|
||||
name = "Chapter"
|
||||
val date = obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content
|
||||
date_upload = if (date.contains("day")) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, date.filter { it.isDigit() }.toInt() * -1)
|
||||
}.timeInMillis
|
||||
} else {
|
||||
DATE_FORMAT.parse(obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content)?.time ?: 0
|
||||
}
|
||||
chapter_number = 1f
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val slug = chapter.url
|
||||
return GET("$baseUrl/api/comics/$slug/images")
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
json.parseToJsonElement(response.body.string()).jsonObject["images"]!!.jsonArray.map {
|
||||
val imgObj = it.jsonObject
|
||||
val index = imgObj["page"]!!.jsonPrimitive.int
|
||||
val imgUrl = imgObj["source_url"]!!.jsonPrimitive.content
|
||||
Page(index, "", imgUrl)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Authorization
|
||||
|
||||
protected fun authIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (username.isEmpty() or password.isEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
if (token.isEmpty()) {
|
||||
token = this.login(chain, username, password)
|
||||
}
|
||||
val authRequest = request.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
private fun login(chain: Interceptor.Chain, username: String, password: String): String {
|
||||
val jsonObject = buildJsonObject {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("remember_me", true)
|
||||
}
|
||||
val body = jsonObject.toString().toRequestBody(MEDIA_TYPE)
|
||||
val response = chain.proceed(POST("$baseUrl/api/login", headers, body))
|
||||
if (response.code == 401) {
|
||||
throw IOException("Failed to login, check if username and password are correct")
|
||||
}
|
||||
try {
|
||||
// Returns access token as a string, unless unparseable
|
||||
return json.parseToJsonElement(response.body.string()).jsonObject["auth"]!!.jsonObject["access-token"]!!.jsonPrimitive.content
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw IOException("Cannot parse login response body")
|
||||
}
|
||||
}
|
||||
|
||||
private var token: String = ""
|
||||
private val username by lazy { getPrefUsername() }
|
||||
private val password by lazy { getPrefPassword() }
|
||||
|
||||
// Preferences
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
screen.addPreference(screen.editTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
|
||||
screen.addPreference(screen.editTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password, true))
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
if (isPassword) {
|
||||
setOnBindEditTextListener {
|
||||
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
|
||||
private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
|
||||
|
||||
// Filters
|
||||
|
||||
private class SortFilter(sortPairs: List<Pair<String, String>>) : Filter.Select<String>("Sort By", sortPairs.map { it.first }.toTypedArray())
|
||||
private class OrderFilter(orderPairs: List<Pair<String, String>>) : Filter.Select<String>("Order By", orderPairs.map { it.first }.toTypedArray())
|
||||
private class DurationFilter(durationPairs: List<Pair<String, String>>) : Filter.Select<String>("Duration", durationPairs.map { it.first }.toTypedArray())
|
||||
private class AttributeFilter(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class AttributesGroupFilter(attributePairs: List<Pair<String, String>>) : Filter.Group<AttributeFilter>("Attributes", attributePairs.map { AttributeFilter(it.first, it.second) })
|
||||
private class StatusFilter(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class StatusGroupFilter(attributePairs: List<Pair<String, String>>) : Filter.Group<StatusFilter>("Status", attributePairs.map { StatusFilter(it.first, it.second) })
|
||||
|
||||
private class CategoriesFilter : LookupFilter("Categories", "categories", "category")
|
||||
private class TagsFilter : LookupFilter("Tags", "tags", "tag")
|
||||
private class ArtistsFilter : LookupFilter("Artists", "artists", "artist")
|
||||
private class GroupsFilter : LookupFilter("Groups", "groups", "group")
|
||||
private class CharactersFilter : LookupFilter("Characters", "characters", "character")
|
||||
private class ParodiesFilter : LookupFilter("Parodies", "parodies", "parody")
|
||||
private class LanguagesFilter : LookupFilter("Other Languages", "languages", "language")
|
||||
open class LookupFilter(name: String, val uri: String, val singularName: String) : Filter.Text(name)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
SortFilter(getSortPairs()),
|
||||
OrderFilter(getOrderPairs()),
|
||||
DurationFilter(getDurationPairs()),
|
||||
Filter.Header("Separate terms with commas (,)"),
|
||||
CategoriesFilter(),
|
||||
TagsFilter(),
|
||||
ArtistsFilter(),
|
||||
GroupsFilter(),
|
||||
CharactersFilter(),
|
||||
ParodiesFilter(),
|
||||
LanguagesFilter(),
|
||||
AttributesGroupFilter(getAttributePairs()),
|
||||
StatusGroupFilter(getStatusPairs()),
|
||||
)
|
||||
|
||||
private fun getSortPairs() = listOf(
|
||||
Pair("Upload Date", "uploaded_at"),
|
||||
Pair("Title", "title"),
|
||||
Pair("Pages", "pages"),
|
||||
Pair("Favorites", "favorites"),
|
||||
Pair("Popularity", "popularity"),
|
||||
)
|
||||
|
||||
private fun getOrderPairs() = listOf(
|
||||
Pair("Descending", "desc"),
|
||||
Pair("Ascending", "asc"),
|
||||
)
|
||||
|
||||
private fun getDurationPairs() = listOf(
|
||||
Pair("Today", "day"),
|
||||
Pair("This Week", "week"),
|
||||
Pair("This Month", "month"),
|
||||
Pair("This Year", "year"),
|
||||
Pair("All Time", "all"),
|
||||
)
|
||||
|
||||
private fun getAttributePairs() = listOf(
|
||||
Pair("Translated", "translated"),
|
||||
Pair("Speechless", "speechless"),
|
||||
Pair("Rewritten", "rewritten"),
|
||||
)
|
||||
|
||||
private fun getStatusPairs() = listOf(
|
||||
Pair("Ongoing", "ongoing"),
|
||||
Pair("Complete", "complete"),
|
||||
Pair("On Hold", "onhold"),
|
||||
Pair("Canceled", "canceled"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
private const val USERNAME_TITLE = "Username"
|
||||
private const val USERNAME_DEFAULT = ""
|
||||
private const val PASSWORD_TITLE = "Password"
|
||||
private const val PASSWORD_DEFAULT = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.kanade.tachiyomi.multisrc.hentaihand
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class HentaiHandGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "hentaihand"
|
||||
|
||||
override val themeClass = "HentaiHand"
|
||||
|
||||
override val baseVersionCode: Int = 2
|
||||
|
||||
override val sources = listOf(
|
||||
MultiLang("HentaiHand", "https://hentaihand.com", listOf("all", "ja", "en", "zh", "bg", "ceb", "other", "tl", "ar", "el", "sr", "jv", "uk", "tr", "fi", "la", "mn", "eo", "sk", "cs", "ko", "ru", "it", "es", "pt-BR", "th", "fr", "id", "vi", "de", "pl", "hu", "nl", "hi"), isNsfw = true, overrideVersionCode = 5),
|
||||
MultiLang("nHentai.com (unoriginal)", "https://nhentai.com", listOf("all", "ja", "en", "zh", "bg", "ceb", "other", "tl", "ar", "el", "sr", "jv", "uk", "tr", "fi", "la", "mn", "eo", "sk", "cs", "ko", "ru", "it", "es", "pt-BR", "th", "fr", "id", "vi", "de", "pl", "hu", "nl", "hi"), isNsfw = true, className = "NHentaiComFactory", overrideVersionCode = 4),
|
||||
SingleLang("HentaiSphere", "https://hentaisphere.com", "en", isNsfw = true),
|
||||
SingleLang("ManhwaClub", "https://manhwa.club", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("ReadManhwa", "https://readmanhwa.com", "en", isNsfw = true, overrideVersionCode = 10),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
HentaiHandGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package eu.kanade.tachiyomi.multisrc.kemono
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.blackholeSink
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.min
|
||||
|
||||
open class Kemono(
|
||||
override val name: String,
|
||||
private val defaultUrl: String,
|
||||
override val lang: String = "all",
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
override val supportsLatest = true
|
||||
|
||||
private val mirrorUrls get() = arrayOf(defaultUrl, defaultUrl.removeSuffix(".party") + ".su")
|
||||
|
||||
override val client = network.client.newBuilder().rateLimit(2).build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences =
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
|
||||
override val baseUrl = preferences.getString(BASE_URL_PREF, defaultUrl)!!
|
||||
|
||||
private val apiPath = "api/v1"
|
||||
|
||||
private val imgCdnUrl = when (name) {
|
||||
"Kemono" -> baseUrl
|
||||
else -> defaultUrl
|
||||
}.replace("//", "//img.")
|
||||
|
||||
private fun String.formatAvatarUrl(): String = removePrefix("https://").replaceBefore('/', imgCdnUrl)
|
||||
|
||||
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return Observable.fromCallable {
|
||||
fetchNewDesignListing(page, "/artists", compareByDescending { it.favorited })
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return Observable.fromCallable {
|
||||
fetchNewDesignListing(page, "/artists/updated", compareByDescending { it.updatedDate })
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchNewDesignListing(
|
||||
page: Int,
|
||||
path: String,
|
||||
comparator: Comparator<KemonoCreatorDto>,
|
||||
): MangasPage {
|
||||
val baseUrl = baseUrl
|
||||
return if (page == 1) {
|
||||
val document = client.newCall(GET(baseUrl + path, headers)).execute().asJsoup()
|
||||
val cardList = document.selectFirst(Evaluator.Class("card-list__items"))!!
|
||||
val creators = cardList.children().map {
|
||||
SManga.create().apply {
|
||||
url = it.attr("href")
|
||||
title = it.selectFirst(Evaluator.Class("user-card__name"))!!.ownText()
|
||||
author = it.selectFirst(Evaluator.Class("user-card__service"))!!.ownText()
|
||||
thumbnail_url = it.selectFirst(Evaluator.Tag("img"))!!.absUrl("src").formatAvatarUrl()
|
||||
description = PROMPT
|
||||
initialized = true
|
||||
}
|
||||
}.filterUnsupported()
|
||||
MangasPage(creators, true).also { cacheCreators() }
|
||||
} else {
|
||||
fetchCreatorsPage(page) { it.apply { sortWith(comparator) } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.fromCallable {
|
||||
if (query.isBlank()) throw Exception("Query is empty")
|
||||
fetchCreatorsPage(page) { all ->
|
||||
val result = all.filterTo(ArrayList()) { it.name.contains(query, ignoreCase = true) }
|
||||
if (result.isEmpty()) return@fetchCreatorsPage emptyList()
|
||||
if (result[0].favorited != -1) {
|
||||
result.sortByDescending { it.favorited }
|
||||
} else {
|
||||
result.sortByDescending { it.updatedDate }
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchCreatorsPage(
|
||||
page: Int,
|
||||
block: (ArrayList<KemonoCreatorDto>) -> List<KemonoCreatorDto>,
|
||||
): MangasPage {
|
||||
val imgCdnUrl = this.imgCdnUrl
|
||||
val response = client.newCall(GET("$baseUrl/$apiPath/creators", headers)).execute()
|
||||
val allCreators = block(response.parseAs())
|
||||
val count = allCreators.size
|
||||
val fromIndex = (page - 1) * NEW_PAGE_SIZE
|
||||
val toIndex = min(count, fromIndex + NEW_PAGE_SIZE)
|
||||
val creators = allCreators.subList(fromIndex, toIndex)
|
||||
.map { it.toSManga(imgCdnUrl) }
|
||||
.filterUnsupported()
|
||||
return MangasPage(creators, toIndex < count)
|
||||
}
|
||||
|
||||
private fun cacheCreators() {
|
||||
val callback = object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) =
|
||||
response.body.source().run {
|
||||
readAll(blackholeSink())
|
||||
close()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) = Unit
|
||||
}
|
||||
client.newCall(GET("$baseUrl/$apiPath/creators", headers)).enqueue(callback)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
|
||||
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
manga.thumbnail_url = manga.thumbnail_url!!.formatAvatarUrl()
|
||||
return Observable.just(manga)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
|
||||
KemonoPostDto.dateFormat.timeZone = when (manga.author) {
|
||||
"Pixiv Fanbox", "Fantia" -> TimeZone.getTimeZone("GMT+09:00")
|
||||
else -> TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
val maxPosts = preferences.getString(POST_PAGES_PREF, POST_PAGES_DEFAULT)!!
|
||||
.toInt().coerceAtMost(POST_PAGES_MAX) * POST_PAGE_SIZE
|
||||
var offset = 0
|
||||
var hasNextPage = true
|
||||
val result = ArrayList<SChapter>()
|
||||
while (offset < maxPosts && hasNextPage) {
|
||||
val request = GET("$baseUrl/$apiPath${manga.url}?limit=$POST_PAGE_SIZE&o=$offset", headers)
|
||||
val page: List<KemonoPostDto> = client.newCall(request).execute().parseAs()
|
||||
page.forEach { post -> if (post.images.isNotEmpty()) result.add(post.toSChapter()) }
|
||||
offset += POST_PAGE_SIZE
|
||||
hasNextPage = page.size == POST_PAGE_SIZE
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
GET("$baseUrl/$apiPath${chapter.url}", headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val post: KemonoPostDto = response.parseAs()
|
||||
return post.images.mapIndexed { i, path -> Page(i, imageUrl = baseUrl + path) }
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imageUrl = page.imageUrl!!
|
||||
if (!preferences.getBoolean(USE_LOW_RES_IMG, false)) return GET(imageUrl, headers)
|
||||
val index = imageUrl.indexOf('/', startIndex = 8) // https://
|
||||
val url = buildString {
|
||||
append(imageUrl, 0, index)
|
||||
append("/thumbnail")
|
||||
append(imageUrl, index, imageUrl.length)
|
||||
}
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T = use {
|
||||
json.decodeFromStream(it.body.byteStream())
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = POST_PAGES_PREF
|
||||
title = "Maximum posts to load"
|
||||
summary = "Loading more posts costs more time and network traffic.\nCurrently: %s"
|
||||
entryValues = (1..POST_PAGES_MAX).map { it.toString() }.toTypedArray()
|
||||
entries = (1..POST_PAGES_MAX).map {
|
||||
if (it == 1) "1 page ($POST_PAGE_SIZE posts)" else "$it pages (${it * POST_PAGE_SIZE} posts)"
|
||||
}.toTypedArray()
|
||||
setDefaultValue(POST_PAGES_DEFAULT)
|
||||
}.let { screen.addPreference(it) }
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = BASE_URL_PREF
|
||||
title = "Mirror URL"
|
||||
summary = "%s\nRequires app restart to take effect"
|
||||
entries = mirrorUrls
|
||||
entryValues = mirrorUrls
|
||||
setDefaultValue(defaultUrl)
|
||||
}.let(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = USE_LOW_RES_IMG
|
||||
title = "Use low resolution images"
|
||||
summary = "Reduce load time significantly. When turning off, clear chapter cache to remove cached low resolution images."
|
||||
setDefaultValue(false)
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NEW_PAGE_SIZE = 50
|
||||
const val PROMPT = "You can change how many posts to load in the extension preferences."
|
||||
|
||||
private const val POST_PAGE_SIZE = 50
|
||||
private const val POST_PAGES_PREF = "POST_PAGES"
|
||||
private const val POST_PAGES_DEFAULT = "1"
|
||||
private const val POST_PAGES_MAX = 50
|
||||
|
||||
private fun List<SManga>.filterUnsupported() = filterNot { it.author == "Discord" }
|
||||
|
||||
private const val BASE_URL_PREF = "BASE_URL"
|
||||
private const val USE_LOW_RES_IMG = "USE_LOW_RES_IMG"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package eu.kanade.tachiyomi.multisrc.kemono
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.double
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class KemonoCreatorDto(
|
||||
private val id: String,
|
||||
val name: String,
|
||||
private val service: String,
|
||||
private val updated: JsonPrimitive,
|
||||
val favorited: Int = -1,
|
||||
) {
|
||||
val updatedDate get() = when {
|
||||
updated.isString -> dateFormat.parse(updated.content)?.time ?: 0
|
||||
else -> (updated.double * 1000).toLong()
|
||||
}
|
||||
|
||||
fun toSManga(imgCdnUrl: String) = SManga.create().apply {
|
||||
url = "/$service/user/$id" // should be /server/ for Discord but will be filtered anyway
|
||||
title = name
|
||||
author = service.serviceName()
|
||||
thumbnail_url = "$imgCdnUrl/icons/$service/$id"
|
||||
description = Kemono.PROMPT
|
||||
initialized = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val dateFormat by lazy { getApiDateFormat() }
|
||||
|
||||
fun String.serviceName() = when (this) {
|
||||
"fanbox" -> "Pixiv Fanbox"
|
||||
"subscribestar" -> "SubscribeStar"
|
||||
"dlsite" -> "DLsite"
|
||||
"onlyfans" -> "OnlyFans"
|
||||
else -> replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class KemonoPostDto(
|
||||
private val id: String,
|
||||
private val service: String,
|
||||
private val user: String,
|
||||
private val title: String,
|
||||
private val added: String,
|
||||
private val published: String?,
|
||||
private val edited: String?,
|
||||
private val file: KemonoFileDto,
|
||||
private val attachments: List<KemonoAttachmentDto>,
|
||||
) {
|
||||
val images: List<String>
|
||||
get() = buildList(attachments.size + 1) {
|
||||
if (file.path != null) add(KemonoAttachmentDto(file.name!!, file.path))
|
||||
addAll(attachments)
|
||||
}.filter {
|
||||
when (it.name.substringAfterLast('.').lowercase()) {
|
||||
"png", "jpg", "gif", "jpeg", "webp" -> true
|
||||
else -> false
|
||||
}
|
||||
}.distinctBy { it.path }.map { it.toString() }
|
||||
|
||||
fun toSChapter() = SChapter.create().apply {
|
||||
val postDate = dateFormat.parse(edited ?: published ?: added)
|
||||
|
||||
url = "/$service/user/$user/post/$id"
|
||||
date_upload = postDate?.time ?: 0
|
||||
name = title.ifBlank {
|
||||
val postDateString = when {
|
||||
postDate != null && postDate.time != 0L -> chapterNameDateFormat.format(postDate)
|
||||
else -> "unknown date"
|
||||
}
|
||||
|
||||
"Post from $postDateString"
|
||||
}
|
||||
chapter_number = -2f
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateFormat by lazy { getApiDateFormat() }
|
||||
val chapterNameDateFormat by lazy { getChapterNameDateFormat() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class KemonoFileDto(val name: String? = null, val path: String? = null)
|
||||
|
||||
@Serializable
|
||||
class KemonoAttachmentDto(val name: String, val path: String) {
|
||||
override fun toString() = "$path?f=$name"
|
||||
}
|
||||
|
||||
private fun getApiDateFormat() =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH)
|
||||
|
||||
private fun getChapterNameDateFormat() =
|
||||
SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss", Locale.ENGLISH)
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.multisrc.kemono
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class KemonoGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themeClass = "Kemono"
|
||||
|
||||
override val themePkg = "kemono"
|
||||
|
||||
override val baseVersionCode = 8
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Kemono", "https://kemono.party", "all", isNsfw = true),
|
||||
SingleLang("Coomer", "https://coomer.party", "all", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
KemonoGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.multisrc.libgroup
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class LibGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "libgroup"
|
||||
|
||||
override val themeClass = "LibGroup"
|
||||
|
||||
override val baseVersionCode: Int = 25
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("MangaLib", "https://mangalib.me", "ru", overrideVersionCode = 74),
|
||||
SingleLang("HentaiLib", "https://hentailib.me", "ru", isNsfw = true, overrideVersionCode = 19),
|
||||
SingleLang("YaoiLib", "https://v1.slashlib.me", "ru", isNsfw = true, overrideVersionCode = 2),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
LibGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
package eu.kanade.tachiyomi.multisrc.libgroup
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
abstract class LibGroup(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_${id}_2", 0x0000)
|
||||
}
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private fun imageContentTypeIntercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val response = chain.proceed(originalRequest)
|
||||
val urlRequest = originalRequest.url.toString()
|
||||
val possibleType = urlRequest.substringAfterLast("/").substringBefore("?").split(".")
|
||||
return if (urlRequest.contains("/chapters/") and (possibleType.size == 2)) {
|
||||
val realType = possibleType[1]
|
||||
val image = response.body.byteString().toResponseBody("image/$realType".toMediaType())
|
||||
response.newBuilder().body(image).build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(3)
|
||||
.connectTimeout(5, TimeUnit.MINUTES)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.addNetworkInterceptor { imageContentTypeIntercept(it) }
|
||||
.addInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 419) {
|
||||
throw IOException("HTTP error ${response.code}. Проверьте сайт. Для завершения авторизации необходимо перезапустить приложение с полной остановкой.")
|
||||
}
|
||||
if (response.code == 404) {
|
||||
throw IOException("HTTP error ${response.code}. Проверьте сайт. Попробуйте авторизоваться через WebView\uD83C\uDF0E︎ и обновите список глав.")
|
||||
}
|
||||
return@addInterceptor response
|
||||
}
|
||||
.build()
|
||||
|
||||
private val userAgentMobile = "Mozilla/5.0 (Linux; Android 10; SM-G980F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
|
||||
private val userAgentRandomizer = "${Random.nextInt().absoluteValue}"
|
||||
|
||||
protected var csrfToken: String = ""
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
// User-Agent required for authorization through third-party accounts (mobile version for correct display in WebView)
|
||||
add("User-Agent", userAgentMobile)
|
||||
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
add("Referer", baseUrl)
|
||||
}
|
||||
|
||||
private fun imgHeader() = Headers.Builder().apply {
|
||||
add("User-Agent", userAgentMobile)
|
||||
add("Accept", "image/avif,image/webp,*/*")
|
||||
add("Referer", baseUrl)
|
||||
}.build()
|
||||
|
||||
protected fun catalogHeaders() = Headers.Builder()
|
||||
.apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36 Edg/100.0.$userAgentRandomizer")
|
||||
add("Accept", "application/json, text/plain, */*")
|
||||
add("X-Requested-With", "XMLHttpRequest")
|
||||
add("x-csrf-token", csrfToken)
|
||||
}
|
||||
.build()
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("Not used") // popularMangaRequest()
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
if (csrfToken.isEmpty()) {
|
||||
return client.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.flatMap { response ->
|
||||
// Obtain token
|
||||
val resBody = response.body.string()
|
||||
csrfToken = "_token\" content=\"(.*)\"".toRegex().find(resBody)!!.groups[1]!!.value
|
||||
return@flatMap fetchLatestMangaFromApi(page)
|
||||
}
|
||||
}
|
||||
return fetchLatestMangaFromApi(page)
|
||||
}
|
||||
|
||||
private fun fetchLatestMangaFromApi(page: Int): Observable<MangasPage> {
|
||||
return client.newCall(POST("$baseUrl/filterlist?dir=desc&sort=last_chapter_at&page=$page&chapters[min]=1", catalogHeaders()))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
latestUpdatesParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
popularMangaParse(response)
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
if (csrfToken.isEmpty()) {
|
||||
return client.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.flatMap { response ->
|
||||
// Obtain token
|
||||
val resBody = response.body.string()
|
||||
csrfToken = "_token\" content=\"(.*)\"".toRegex().find(resBody)!!.groups[1]!!.value
|
||||
return@flatMap fetchPopularMangaFromApi(page)
|
||||
}
|
||||
}
|
||||
return fetchPopularMangaFromApi(page)
|
||||
}
|
||||
|
||||
private fun fetchPopularMangaFromApi(page: Int): Observable<MangasPage> {
|
||||
return client.newCall(POST("$baseUrl/filterlist?dir=desc&sort=views&page=$page&chapters[min]=1", catalogHeaders()))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
popularMangaParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val resBody = response.body.string()
|
||||
val result = json.decodeFromString<JsonObject>(resBody)
|
||||
val items = result["items"]!!.jsonObject
|
||||
val popularMangas = items["data"]?.jsonArray?.map { popularMangaFromElement(it) }
|
||||
if (popularMangas != null) {
|
||||
val hasNextPage = items["next_page_url"]?.jsonPrimitive?.contentOrNull != null
|
||||
return MangasPage(popularMangas, hasNextPage)
|
||||
}
|
||||
return MangasPage(emptyList(), false)
|
||||
}
|
||||
|
||||
// Popular cross Latest
|
||||
private fun popularMangaFromElement(el: JsonElement) = SManga.create().apply {
|
||||
val slug = el.jsonObject["slug"]!!.jsonPrimitive.content
|
||||
title = when {
|
||||
isEng.equals("rus") && el.jsonObject["rus_name"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> el.jsonObject["rus_name"]!!.jsonPrimitive.content
|
||||
isEng.equals("eng") && el.jsonObject["eng_name"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> el.jsonObject["eng_name"]!!.jsonPrimitive.content
|
||||
else -> el.jsonObject["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
thumbnail_url = if (el.jsonObject["coverImage"] != null) {
|
||||
el.jsonObject["coverImage"]!!.jsonPrimitive.content
|
||||
} else {
|
||||
"/uploads/cover/" + slug + "/cover/" + el.jsonObject["cover"]!!.jsonPrimitive.content + "_250x350.jpg"
|
||||
}
|
||||
if (!thumbnail_url!!.contains("://")) {
|
||||
thumbnail_url = baseUrl + thumbnail_url
|
||||
}
|
||||
url = "/$slug"
|
||||
}
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
val dataStr = document
|
||||
.toString()
|
||||
.substringAfter("window.__DATA__ = ")
|
||||
.substringBefore("window._SITE_COLOR_")
|
||||
.substringBeforeLast(";")
|
||||
|
||||
val dataManga = json.decodeFromString<JsonObject>(dataStr)["manga"]
|
||||
|
||||
val manga = SManga.create()
|
||||
|
||||
val body = document.select("div.media-info-list").first()!!
|
||||
val rawCategory = document.select(".media-short-info a.media-short-info__item").text()
|
||||
val category = when {
|
||||
rawCategory == "Комикс западный" -> "Комикс"
|
||||
rawCategory.isNotBlank() -> rawCategory
|
||||
else -> "Манга"
|
||||
}
|
||||
|
||||
val rawAgeStop = document.select(".media-short-info .media-short-info__item[data-caution]").text()
|
||||
|
||||
val ratingValue = document.select(".media-rating__value").last()!!.text().toFloat()
|
||||
val ratingVotes = document.select(".media-rating__votes").last()!!.text()
|
||||
val ratingStar = when {
|
||||
ratingValue > 9.5 -> "★★★★★"
|
||||
ratingValue > 8.5 -> "★★★★✬"
|
||||
ratingValue > 7.5 -> "★★★★☆"
|
||||
ratingValue > 6.5 -> "★★★✬☆"
|
||||
ratingValue > 5.5 -> "★★★☆☆"
|
||||
ratingValue > 4.5 -> "★★✬☆☆"
|
||||
ratingValue > 3.5 -> "★★☆☆☆"
|
||||
ratingValue > 2.5 -> "★✬☆☆☆"
|
||||
ratingValue > 1.5 -> "★☆☆☆☆"
|
||||
ratingValue > 0.5 -> "✬☆☆☆☆"
|
||||
else -> "☆☆☆☆☆"
|
||||
}
|
||||
val genres = document.select(".media-tags > a").map { it.text().capitalize() }
|
||||
manga.title = when {
|
||||
isEng.equals("rus") && dataManga!!.jsonObject["rusName"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> dataManga.jsonObject["rusName"]!!.jsonPrimitive.content
|
||||
isEng.equals("eng") && dataManga!!.jsonObject["engName"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> dataManga.jsonObject["engName"]!!.jsonPrimitive.content
|
||||
else -> dataManga!!.jsonObject["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
manga.thumbnail_url = document.select(".media-header__cover").attr("src")
|
||||
manga.author = body.select("div.media-info-list__title:contains(Автор) + div a").joinToString { it.text() }
|
||||
manga.artist = body.select("div.media-info-list__title:contains(Художник) + div a").joinToString { it.text() }
|
||||
|
||||
val statusTranslate = body.select("div.media-info-list__title:contains(Статус перевода) + div").text().lowercase(Locale.ROOT)
|
||||
val statusTitle = body.select("div.media-info-list__title:contains(Статус тайтла) + div").text().lowercase(Locale.ROOT)
|
||||
|
||||
manga.status = if (document.html().contains("paper empty section")
|
||||
) {
|
||||
SManga.LICENSED
|
||||
} else {
|
||||
when {
|
||||
statusTranslate.contains("завершен") && statusTitle.contains("приостановлен") || statusTranslate.contains("заморожен") || statusTranslate.contains("заброшен") -> SManga.ON_HIATUS
|
||||
statusTranslate.contains("завершен") && statusTitle.contains("выпуск прекращён") -> SManga.CANCELLED
|
||||
statusTranslate.contains("продолжается") -> SManga.ONGOING
|
||||
statusTranslate.contains("завершен") -> SManga.COMPLETED
|
||||
else -> when (statusTitle) {
|
||||
"онгоинг" -> SManga.ONGOING
|
||||
"анонс" -> SManga.ONGOING
|
||||
"завершён" -> SManga.COMPLETED
|
||||
"приостановлен" -> SManga.ON_HIATUS
|
||||
"выпуск прекращён" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
manga.genre = category + ", " + rawAgeStop + ", " + genres.joinToString { it.trim() }
|
||||
|
||||
val altName = if (dataManga.jsonObject["altNames"]?.jsonArray.orEmpty().isNotEmpty()) {
|
||||
"Альтернативные названия:\n" + dataManga.jsonObject["altNames"]!!.jsonArray.joinToString(" / ") { it.jsonPrimitive.content } + "\n\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val mediaNameLanguage = when {
|
||||
isEng.equals("eng") && dataManga.jsonObject["rusName"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> dataManga.jsonObject["rusName"]!!.jsonPrimitive.content + "\n"
|
||||
isEng.equals("rus") && dataManga.jsonObject["engName"]?.jsonPrimitive?.content.orEmpty().isNotEmpty() -> dataManga.jsonObject["engName"]!!.jsonPrimitive.content + "\n"
|
||||
else -> ""
|
||||
}
|
||||
manga.description = mediaNameLanguage + ratingStar + " " + ratingValue + " (голосов: " + ratingVotes + ")\n" + altName + document.select(".media-description__text").text()
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code == 404 && response.asJsoup().select(".m-menu__sign-in").isNotEmpty()) throw Exception("HTTP error ${response.code}. Для просмотра 18+ контента необходима авторизация через WebView\uD83C\uDF0E︎") else throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
}
|
||||
.map { response ->
|
||||
mangaDetailsParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val rawAgeStop = document.select(".media-short-info .media-short-info__item[data-caution]").text()
|
||||
if (rawAgeStop == "18+" && document.select(".m-menu__sign-in").isNotEmpty()) {
|
||||
throw Exception("Для просмотра 18+ контента необходима авторизация через WebView\uD83C\uDF0E︎")
|
||||
}
|
||||
val redirect = document.html()
|
||||
if (redirect.contains("paper empty section")) {
|
||||
throw Exception("Лицензировано - Нет глав")
|
||||
}
|
||||
val dataStr = document
|
||||
.toString()
|
||||
.substringAfter("window.__DATA__ = ")
|
||||
.substringBefore("window._SITE_COLOR_")
|
||||
.substringBeforeLast(";")
|
||||
|
||||
val data = json.decodeFromString<JsonObject>(dataStr)
|
||||
val chaptersList = data["chapters"]!!.jsonObject["list"]?.jsonArray
|
||||
val slug = data["manga"]!!.jsonObject["slug"]!!.jsonPrimitive.content
|
||||
val branches = data["chapters"]!!.jsonObject["branches"]!!.jsonArray.reversed()
|
||||
val teams = data["chapters"]!!.jsonObject["teams"]!!.jsonArray
|
||||
val sortingList = preferences.getString(SORTING_PREF, "ms_mixing")
|
||||
val auth = data["auth"]!!.jsonPrimitive.content
|
||||
val userId = if (auth == "true") data["user"]!!.jsonObject["id"]!!.jsonPrimitive.content else "not"
|
||||
|
||||
val chapters: List<SChapter>? = if (branches.isNotEmpty()) {
|
||||
sortChaptersByTranslator(sortingList, chaptersList, slug, userId, branches)
|
||||
} else {
|
||||
chaptersList
|
||||
?.filter { it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 && it.jsonObject["price"]?.jsonPrimitive?.intOrNull == 0 }
|
||||
?.map { chapterFromElement(it, sortingList, slug, userId, null, null, teams, chaptersList) }
|
||||
}
|
||||
|
||||
return chapters ?: emptyList()
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code == 404 && response.asJsoup().select(".m-menu__sign-in").isNotEmpty()) throw Exception("HTTP error ${response.code}. Для просмотра 18+ контента необходима авторизация через WebView\uD83C\uDF0E︎") else throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
}
|
||||
.map { response ->
|
||||
chapterListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortChaptersByTranslator
|
||||
(sortingList: String?, chaptersList: JsonArray?, slug: String, userId: String, branches: List<JsonElement>): List<SChapter>? {
|
||||
var chapters: List<SChapter>? = null
|
||||
val volume = "(?<=/v)[0-9]+(?=/c[0-9]+)".toRegex()
|
||||
val tempChaptersList = mutableListOf<SChapter>()
|
||||
for (currentBranch in branches.withIndex()) {
|
||||
val branch = branches[currentBranch.index]
|
||||
val teamId = branch.jsonObject["id"]!!.jsonPrimitive.int
|
||||
val teams = branch.jsonObject["teams"]!!.jsonArray
|
||||
val isActive = teams.filter { it.jsonObject["is_active"]?.jsonPrimitive?.intOrNull == 1 }
|
||||
val teamsBranch = if (isActive.size == 1) {
|
||||
isActive[0].jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
} else if (teams.isNotEmpty() && isActive.isEmpty()) {
|
||||
teams[0].jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
} else {
|
||||
"Неизвестный"
|
||||
}
|
||||
chapters = chaptersList
|
||||
?.filter { it.jsonObject["branch_id"]?.jsonPrimitive?.intOrNull == teamId && it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 }
|
||||
?.map { chapterFromElement(it, sortingList, slug, userId, teamId, branches) }
|
||||
when (sortingList) {
|
||||
"ms_mixing" -> {
|
||||
chapters?.let {
|
||||
if ((tempChaptersList.size < it.size) && !groupTranslates.contains(teamsBranch.toString())) {
|
||||
tempChaptersList.addAll(0, it)
|
||||
} else {
|
||||
tempChaptersList.addAll(it)
|
||||
}
|
||||
}
|
||||
chapters = tempChaptersList.distinctBy { volume.find(it.url)?.value + "--" + it.chapter_number }.sortedWith(compareBy({ -it.chapter_number }, { volume.find(it.url)?.value }))
|
||||
}
|
||||
"ms_combining" -> {
|
||||
if (!groupTranslates.contains(teamsBranch.toString())) {
|
||||
chapters?.let { tempChaptersList.addAll(it) }
|
||||
}
|
||||
chapters = tempChaptersList
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private fun chapterFromElement
|
||||
(chapterItem: JsonElement, sortingList: String?, slug: String, userId: String, teamIdParam: Int? = null, branches: List<JsonElement>? = null, teams: List<JsonElement>? = null, chaptersList: JsonArray? = null): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
|
||||
val volume = chapterItem.jsonObject["chapter_volume"]!!.jsonPrimitive.int
|
||||
val number = chapterItem.jsonObject["chapter_number"]!!.jsonPrimitive.content
|
||||
val chapterScanlatorId = chapterItem.jsonObject["chapter_scanlator_id"]!!.jsonPrimitive.int
|
||||
val isScanlatorId = teams?.filter { it.jsonObject["id"]?.jsonPrimitive?.intOrNull == chapterScanlatorId }
|
||||
|
||||
val teamId = if (teamIdParam != null) "&bid=$teamIdParam" else ""
|
||||
|
||||
val url = "/$slug/v$volume/c$number?ui=$userId$teamId"
|
||||
|
||||
chapter.setUrlWithoutDomain(url)
|
||||
|
||||
val nameChapter = chapterItem.jsonObject["chapter_name"]?.jsonPrimitive?.contentOrNull
|
||||
val fullNameChapter = "Том $volume. Глава $number"
|
||||
chapter.scanlator = if (teams?.size == 1) teams[0].jsonObject["name"]?.jsonPrimitive?.content else if (isScanlatorId.orEmpty().isNotEmpty()) isScanlatorId!![0].jsonObject["name"]?.jsonPrimitive?.content else branches?.let { getScanlatorTeamName(it, chapterItem) } ?: if ((preferences.getBoolean(isScan_USER, false)) || (chaptersList?.distinctBy { it.jsonObject["username"]!!.jsonPrimitive.content }?.size == 1)) chapterItem.jsonObject["username"]!!.jsonPrimitive.content else null
|
||||
chapter.name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter"
|
||||
chapter.date_upload = simpleDateFormat.parse(chapterItem.jsonObject["chapter_created_at"]!!.jsonPrimitive.content.substringBefore(" "))?.time ?: 0L
|
||||
chapter.chapter_number = number.toFloatOrNull() ?: -1f
|
||||
|
||||
return chapter
|
||||
}
|
||||
|
||||
private fun getScanlatorTeamName(branches: List<JsonElement>, chapterItem: JsonElement): String? {
|
||||
var scanlatorData: String? = null
|
||||
for (currentBranch in branches.withIndex()) {
|
||||
val branch = branches[currentBranch.index].jsonObject
|
||||
val teams = branch["teams"]!!.jsonArray
|
||||
if (chapterItem.jsonObject["branch_id"]!!.jsonPrimitive.int == branch["id"]!!.jsonPrimitive.int && teams.isNotEmpty()) {
|
||||
for (currentTeam in teams.withIndex()) {
|
||||
val team = teams[currentTeam.index].jsonObject
|
||||
val scanlatorId = chapterItem.jsonObject["chapter_scanlator_id"]!!.jsonPrimitive.int
|
||||
if ((scanlatorId == team.jsonObject["id"]!!.jsonPrimitive.int) ||
|
||||
(scanlatorId == 0 && team["is_active"]!!.jsonPrimitive.int == 1)
|
||||
) {
|
||||
return team["name"]!!.jsonPrimitive.content
|
||||
} else {
|
||||
scanlatorData = branch["teams"]!!.jsonArray[0].jsonObject["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanlatorData
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
// redirect Регистрация 18+
|
||||
val redirect = document.html()
|
||||
if (!redirect.contains("window.__info")) {
|
||||
if (redirect.contains("auth-layout")) {
|
||||
throw Exception("Для просмотра 18+ контента необходима авторизация через WebView\uD83C\uDF0E︎")
|
||||
}
|
||||
}
|
||||
|
||||
val chapInfo = document
|
||||
.select("script:containsData(window.__info)")
|
||||
.first()!!
|
||||
.html()
|
||||
.split("window.__info = ")
|
||||
.last()
|
||||
.trim()
|
||||
.split(";")
|
||||
.first()
|
||||
|
||||
val chapInfoJson = json.decodeFromString<JsonObject>(chapInfo)
|
||||
val servers = chapInfoJson["servers"]!!.jsonObject.toMap()
|
||||
val imgUrl: String = chapInfoJson["img"]!!.jsonObject["url"]!!.jsonPrimitive.content
|
||||
|
||||
val serverToUse = listOf(isServer, "secondary", "fourth", "main", "compress").distinct()
|
||||
|
||||
// Get pages
|
||||
val pagesArr = document
|
||||
.select("script:containsData(window.__pg)")
|
||||
.first()!!
|
||||
.html()
|
||||
.trim()
|
||||
.removePrefix("window.__pg = ")
|
||||
.removeSuffix(";")
|
||||
|
||||
val pagesJson = json.decodeFromString<JsonArray>(pagesArr)
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
pagesJson.forEach { page ->
|
||||
val keys = servers.keys.filter { serverToUse.indexOf(it) >= 0 }.sortedBy { serverToUse.indexOf(it) }
|
||||
val serversUrls = keys.map {
|
||||
servers[it]?.jsonPrimitive?.contentOrNull + imgUrl + page.jsonObject["u"]!!.jsonPrimitive.content
|
||||
}.distinct().joinToString(separator = ",,") { it }
|
||||
pages.add(Page(page.jsonObject["p"]!!.jsonPrimitive.int, serversUrls))
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
private fun checkImage(url: String): Boolean {
|
||||
val response = client.newCall(GET(url, imgHeader())).execute()
|
||||
return response.isSuccessful && (response.header("content-length", "0")?.toInt()!! > 600)
|
||||
}
|
||||
|
||||
override fun fetchImageUrl(page: Page): Observable<String> {
|
||||
if (page.imageUrl != null) {
|
||||
return Observable.just(page.imageUrl)
|
||||
}
|
||||
|
||||
val urls = page.url.split(",,")
|
||||
|
||||
return Observable.from(urls).filter { checkImage(it) }.first()
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!, imgHeader())
|
||||
}
|
||||
|
||||
// Workaround to allow "Open in browser" use the
|
||||
private fun searchMangaByIdRequest(id: String): Request {
|
||||
return GET("$baseUrl/$id", headers)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
|
||||
val realQuery = query.removePrefix(PREFIX_SLUG_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(realQuery))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/$realQuery"
|
||||
MangasPage(listOf(details), false)
|
||||
}
|
||||
} else {
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (csrfToken.isEmpty()) {
|
||||
val tokenResponse = client.newCall(popularMangaRequest(page)).execute()
|
||||
val resBody = tokenResponse.body.string()
|
||||
csrfToken = "_token\" content=\"(.*)\"".toRegex().find(resBody)!!.groups[1]!!.value
|
||||
}
|
||||
val url = "$baseUrl/filterlist?page=$page&chapters[min]=1".toHttpUrlOrNull()!!.newBuilder()
|
||||
if (query.isNotEmpty()) {
|
||||
url.addQueryParameter("name", query)
|
||||
}
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is CategoryList -> filter.state.forEach { category ->
|
||||
if (category.state) {
|
||||
url.addQueryParameter("types[]", category.id)
|
||||
}
|
||||
}
|
||||
is FormatList -> filter.state.forEach { forma ->
|
||||
if (forma.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(if (forma.isIncluded()) "format[include][]" else "format[exclude][]", forma.id)
|
||||
}
|
||||
}
|
||||
is StatusList -> filter.state.forEach { status ->
|
||||
if (status.state) {
|
||||
url.addQueryParameter("status[]", status.id)
|
||||
}
|
||||
}
|
||||
is StatusTitleList -> filter.state.forEach { title ->
|
||||
if (title.state) {
|
||||
url.addQueryParameter("manga_status[]", title.id)
|
||||
}
|
||||
}
|
||||
is GenreList -> filter.state.forEach { genre ->
|
||||
if (genre.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(if (genre.isIncluded()) "genres[include][]" else "genres[exclude][]", genre.id)
|
||||
}
|
||||
}
|
||||
is OrderBy -> {
|
||||
url.addQueryParameter("dir", if (filter.state!!.ascending) "asc" else "desc")
|
||||
url.addQueryParameter("sort", arrayOf("rate", "name", "views", "created_at", "last_chapter_at", "chap_count")[filter.state!!.index])
|
||||
}
|
||||
is MyList -> filter.state.forEach { favorite ->
|
||||
if (favorite.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(if (favorite.isIncluded()) "bookmarks[include][]" else "bookmarks[exclude][]", favorite.id)
|
||||
}
|
||||
}
|
||||
is RequireChapters -> {
|
||||
if (filter.state == 1) {
|
||||
url.setQueryParameter("chapters[min]", "0")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return POST(url.toString(), catalogHeaders())
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Filters
|
||||
private class SearchFilter(name: String, val id: String) : Filter.TriState(name)
|
||||
private class CheckFilter(name: String, val id: String) : Filter.CheckBox(name)
|
||||
|
||||
private class CategoryList(categories: List<CheckFilter>) : Filter.Group<CheckFilter>("Тип", categories)
|
||||
private class FormatList(formas: List<SearchFilter>) : Filter.Group<SearchFilter>("Формат выпуска", formas)
|
||||
private class StatusList(statuses: List<CheckFilter>) : Filter.Group<CheckFilter>("Статус перевода", statuses)
|
||||
private class StatusTitleList(titles: List<CheckFilter>) : Filter.Group<CheckFilter>("Статус тайтла", titles)
|
||||
private class GenreList(genres: List<SearchFilter>) : Filter.Group<SearchFilter>("Жанры", genres)
|
||||
private class MyList(favorites: List<SearchFilter>) : Filter.Group<SearchFilter>("Мои списки", favorites)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
OrderBy(),
|
||||
CategoryList(getCategoryList()),
|
||||
FormatList(getFormatList()),
|
||||
GenreList(getGenreList()),
|
||||
StatusList(getStatusList()),
|
||||
StatusTitleList(getStatusTitleList()),
|
||||
MyList(getMyList()),
|
||||
RequireChapters(),
|
||||
)
|
||||
|
||||
private class OrderBy : Filter.Sort(
|
||||
"Сортировка",
|
||||
arrayOf("Рейтинг", "Имя", "Просмотры", "Дате добавления", "Дате обновления", "Кол-во глав"),
|
||||
Selection(2, false),
|
||||
)
|
||||
|
||||
private fun getCategoryList() = listOf(
|
||||
CheckFilter("Манга", "1"),
|
||||
CheckFilter("OEL-манга", "4"),
|
||||
CheckFilter("Манхва", "5"),
|
||||
CheckFilter("Маньхуа", "6"),
|
||||
CheckFilter("Руманга", "8"),
|
||||
CheckFilter("Комикс западный", "9"),
|
||||
)
|
||||
|
||||
private fun getFormatList() = listOf(
|
||||
SearchFilter("4-кома (Ёнкома)", "1"),
|
||||
SearchFilter("Сборник", "2"),
|
||||
SearchFilter("Додзинси", "3"),
|
||||
SearchFilter("Сингл", "4"),
|
||||
SearchFilter("В цвете", "5"),
|
||||
SearchFilter("Веб", "6"),
|
||||
)
|
||||
|
||||
private fun getStatusList() = listOf(
|
||||
CheckFilter("Продолжается", "1"),
|
||||
CheckFilter("Завершен", "2"),
|
||||
CheckFilter("Заморожен", "3"),
|
||||
CheckFilter("Заброшен", "4"),
|
||||
)
|
||||
|
||||
private fun getStatusTitleList() = listOf(
|
||||
CheckFilter("Онгоинг", "1"),
|
||||
CheckFilter("Завершён", "2"),
|
||||
CheckFilter("Анонс", "3"),
|
||||
CheckFilter("Приостановлен", "4"),
|
||||
CheckFilter("Выпуск прекращён", "5"),
|
||||
)
|
||||
|
||||
private fun getGenreList() = listOf(
|
||||
SearchFilter("арт", "32"),
|
||||
SearchFilter("боевик", "34"),
|
||||
SearchFilter("боевые искусства", "35"),
|
||||
SearchFilter("вампиры", "36"),
|
||||
SearchFilter("гарем", "37"),
|
||||
SearchFilter("гендерная интрига", "38"),
|
||||
SearchFilter("героическое фэнтези", "39"),
|
||||
SearchFilter("детектив", "40"),
|
||||
SearchFilter("дзёсэй", "41"),
|
||||
SearchFilter("драма", "43"),
|
||||
SearchFilter("игра", "44"),
|
||||
SearchFilter("исекай", "79"),
|
||||
SearchFilter("история", "45"),
|
||||
SearchFilter("киберпанк", "46"),
|
||||
SearchFilter("кодомо", "76"),
|
||||
SearchFilter("комедия", "47"),
|
||||
SearchFilter("махо-сёдзё", "48"),
|
||||
SearchFilter("меха", "49"),
|
||||
SearchFilter("мистика", "50"),
|
||||
SearchFilter("научная фантастика", "51"),
|
||||
SearchFilter("омегаверс", "77"),
|
||||
SearchFilter("повседневность", "52"),
|
||||
SearchFilter("постапокалиптика", "53"),
|
||||
SearchFilter("приключения", "54"),
|
||||
SearchFilter("психология", "55"),
|
||||
SearchFilter("романтика", "56"),
|
||||
SearchFilter("самурайский боевик", "57"),
|
||||
SearchFilter("сверхъестественное", "58"),
|
||||
SearchFilter("сёдзё", "59"),
|
||||
SearchFilter("сёдзё-ай", "60"),
|
||||
SearchFilter("сёнэн", "61"),
|
||||
SearchFilter("сёнэн-ай", "62"),
|
||||
SearchFilter("спорт", "63"),
|
||||
SearchFilter("сэйнэн", "64"),
|
||||
SearchFilter("трагедия", "65"),
|
||||
SearchFilter("триллер", "66"),
|
||||
SearchFilter("ужасы", "67"),
|
||||
SearchFilter("фантастика", "68"),
|
||||
SearchFilter("фэнтези", "69"),
|
||||
SearchFilter("школа", "70"),
|
||||
SearchFilter("эротика", "71"),
|
||||
SearchFilter("этти", "72"),
|
||||
SearchFilter("юри", "73"),
|
||||
SearchFilter("яой", "74"),
|
||||
)
|
||||
|
||||
private fun getMyList() = listOf(
|
||||
SearchFilter("Читаю", "1"),
|
||||
SearchFilter("В планах", "2"),
|
||||
SearchFilter("Брошено", "3"),
|
||||
SearchFilter("Прочитано", "4"),
|
||||
SearchFilter("Любимые", "5"),
|
||||
)
|
||||
|
||||
private class RequireChapters : Filter.Select<String>(
|
||||
"Только проекты с главами",
|
||||
arrayOf("Да", "Все"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SLUG_SEARCH = "slug:"
|
||||
private const val SERVER_PREF = "MangaLibImageServer"
|
||||
|
||||
private const val SORTING_PREF = "MangaLibSorting"
|
||||
private const val SORTING_PREF_Title = "Способ выбора переводчиков"
|
||||
|
||||
private const val isScan_USER = "ScanlatorUsername"
|
||||
private const val isScan_USER_Title = "Альтернативный переводчик"
|
||||
|
||||
private const val TRANSLATORS_TITLE = "Чёрный список переводчиков\n(для красоты через «/» или с новой строки)"
|
||||
private const val TRANSLATORS_DEFAULT = ""
|
||||
|
||||
private const val LANGUAGE_PREF = "MangaLibTitleLanguage"
|
||||
private const val LANGUAGE_PREF_Title = "Выбор языка на обложке"
|
||||
|
||||
private val simpleDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.US) }
|
||||
}
|
||||
|
||||
private var isServer: String? = preferences.getString(SERVER_PREF, "fourth")
|
||||
private var isEng: String? = preferences.getString(LANGUAGE_PREF, "eng")
|
||||
private var groupTranslates: String = preferences.getString(TRANSLATORS_TITLE, TRANSLATORS_DEFAULT)!!
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val serverPref = ListPreference(screen.context).apply {
|
||||
key = SERVER_PREF
|
||||
title = "Сервер изображений"
|
||||
entries = arrayOf("Первый", "Второй", "Сжатия")
|
||||
entryValues = arrayOf("secondary", "fourth", "compress")
|
||||
summary = "%s \n\nВыбор приоритетного сервера изображений. \n" +
|
||||
"По умолчанию «Второй». \n\n" +
|
||||
"ⓘВыбор другого помогает при долгой автоматической смене/загрузке изображений текущего."
|
||||
setDefaultValue("fourth")
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
isServer = newValue.toString()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val sortingPref = ListPreference(screen.context).apply {
|
||||
key = SORTING_PREF
|
||||
title = SORTING_PREF_Title
|
||||
entries = arrayOf(
|
||||
"Полный список (без повторных переводов)",
|
||||
"Все переводы (друг за другом)",
|
||||
)
|
||||
entryValues = arrayOf("ms_mixing", "ms_combining")
|
||||
summary = "%s"
|
||||
setDefaultValue("ms_mixing")
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
preferences.edit().putString(SORTING_PREF, selected).commit()
|
||||
}
|
||||
}
|
||||
val scanlatorUsername = androidx.preference.CheckBoxPreference(screen.context).apply {
|
||||
key = isScan_USER
|
||||
title = isScan_USER_Title
|
||||
summary = "Отображает Ник переводчика если Группа не указана явно."
|
||||
setDefaultValue(false)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean(key, checkValue).commit()
|
||||
}
|
||||
}
|
||||
val titleLanguagePref = ListPreference(screen.context).apply {
|
||||
key = LANGUAGE_PREF
|
||||
title = LANGUAGE_PREF_Title
|
||||
entries = arrayOf("Английский", "Русский")
|
||||
entryValues = arrayOf("eng", "rus")
|
||||
summary = "%s"
|
||||
setDefaultValue("eng")
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val titleLanguage = preferences.edit().putString(LANGUAGE_PREF, newValue as String).commit()
|
||||
val warning = "Если язык обложки не изменился очистите базу данных в приложении (Настройки -> Дополнительно -> Очистить базу данных)"
|
||||
Toast.makeText(screen.context, warning, Toast.LENGTH_LONG).show()
|
||||
titleLanguage
|
||||
}
|
||||
}
|
||||
screen.addPreference(serverPref)
|
||||
screen.addPreference(sortingPref)
|
||||
screen.addPreference(screen.editTextPreference(TRANSLATORS_TITLE, TRANSLATORS_DEFAULT, groupTranslates))
|
||||
screen.addPreference(scanlatorUsername)
|
||||
screen.addPreference(titleLanguagePref)
|
||||
}
|
||||
private fun PreferenceScreen.editTextPreference(title: String, default: String, value: String): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
key = title
|
||||
this.title = title
|
||||
summary = value.replace("/", "\n")
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||
Toast.makeText(context, "Для обновления списка необходимо перезапустить приложение с полной остановкой.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.multisrc.libgroup
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://xxxxlib.me/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 LibUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 0) {
|
||||
val titleid = pathSegments[0]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${LibGroup.PREFIX_SLUG_SEARCH}$titleid")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("LibUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("LibUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
package eu.kanade.tachiyomi.multisrc.madara
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
|
||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class Madara(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US),
|
||||
) : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.setRandomUserAgent(
|
||||
preferences.getPrefUAType(),
|
||||
preferences.getPrefCustomUA(),
|
||||
)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
protected open val json: Json by injectLazy()
|
||||
|
||||
/**
|
||||
* If enabled, will attempt to remove non-manga items in popular and latest.
|
||||
* The filter will not be used in search as the theme doesn't set the CSS class.
|
||||
* Can be disabled if the source incorrectly sets the entry types.
|
||||
*/
|
||||
protected open val filterNonMangaItems = true
|
||||
|
||||
/**
|
||||
* The CSS selector used to filter manga items in popular and latest
|
||||
* if `filterNonMangaItems` is set to `true`. Can be override if needed.
|
||||
* If the flag is set to `false`, it will be empty by default.
|
||||
*/
|
||||
protected open val mangaEntrySelector: String by lazy {
|
||||
if (filterNonMangaItems) ".manga" else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically fetched genres from the source to be used in the filters.
|
||||
*/
|
||||
private var genresList: List<Genre> = emptyList()
|
||||
|
||||
/**
|
||||
* Inner variable to control the genre fetching failed state.
|
||||
*/
|
||||
private var fetchGenresFailed: Boolean = false
|
||||
|
||||
/**
|
||||
* Inner variable to control how much tries the genres request was called.
|
||||
*/
|
||||
private var fetchGenresAttempts: Int = 0
|
||||
|
||||
/**
|
||||
* Disable it if you don't want the genres to be fetched.
|
||||
*/
|
||||
protected open val fetchGenres: Boolean = true
|
||||
|
||||
/**
|
||||
* The path used in the URL for the manga pages. Can be
|
||||
* changed if needed as some sites modify it to other words.
|
||||
*/
|
||||
protected open val mangaSubString = "manga"
|
||||
|
||||
// Popular Manga
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
runCatching { fetchGenres() }
|
||||
return super.popularMangaParse(response)
|
||||
}
|
||||
|
||||
// exclude/filter bilibili manga from list
|
||||
override fun popularMangaSelector() = "div.page-item-detail:not(:has(a[href*='bilibilicomics.com']))$mangaEntrySelector"
|
||||
|
||||
open val popularMangaUrlSelector = "div.post-title a"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
|
||||
with(element) {
|
||||
select(popularMangaUrlSelector).first()?.let {
|
||||
manga.setUrlWithoutDomain(it.attr("abs:href"))
|
||||
manga.title = it.ownText()
|
||||
}
|
||||
|
||||
select("img").first()?.let {
|
||||
manga.thumbnail_url = imageFromElement(it)
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET(
|
||||
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=views",
|
||||
headers = headers,
|
||||
cache = CacheControl.FORCE_NETWORK,
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = searchMangaNextPageSelector()
|
||||
|
||||
// Latest Updates
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
// Even if it's different from the popular manga's list, the relevant classes are the same
|
||||
return popularMangaFromElement(element)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET(
|
||||
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=latest",
|
||||
headers = headers,
|
||||
cache = CacheControl.FORCE_NETWORK,
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val mp = super.latestUpdatesParse(response)
|
||||
val mangas = mp.mangas.distinctBy { it.url }
|
||||
return MangasPage(mangas, mp.hasNextPage)
|
||||
}
|
||||
|
||||
// Search Manga
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(URL_SEARCH_PREFIX)) {
|
||||
val mangaUrl = "$baseUrl/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}"
|
||||
return client.newCall(GET(mangaUrl, headers))
|
||||
.asObservable().map { response ->
|
||||
MangasPage(listOf(mangaDetailsParse(response.asJsoup()).apply { url = "/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}/" }), false)
|
||||
}
|
||||
}
|
||||
|
||||
return client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
// Error message for exceeding last page
|
||||
if (response.code == 404) {
|
||||
error("Already on the Last Page!")
|
||||
} else {
|
||||
throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun searchPage(page: Int): String = "page/$page/"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/${searchPage(page)}".toHttpUrlOrNull()!!.newBuilder()
|
||||
url.addQueryParameter("s", query)
|
||||
url.addQueryParameter("post_type", "wp-manga")
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is AuthorFilter -> {
|
||||
if (filter.state.isNotBlank()) {
|
||||
url.addQueryParameter("author", filter.state)
|
||||
}
|
||||
}
|
||||
is ArtistFilter -> {
|
||||
if (filter.state.isNotBlank()) {
|
||||
url.addQueryParameter("artist", filter.state)
|
||||
}
|
||||
}
|
||||
is YearFilter -> {
|
||||
if (filter.state.isNotBlank()) {
|
||||
url.addQueryParameter("release", filter.state)
|
||||
}
|
||||
}
|
||||
is StatusFilter -> {
|
||||
filter.state.forEach {
|
||||
if (it.state) {
|
||||
url.addQueryParameter("status[]", it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is OrderByFilter -> {
|
||||
if (filter.state != 0) {
|
||||
url.addQueryParameter("m_orderby", filter.toUriPart())
|
||||
}
|
||||
}
|
||||
is AdultContentFilter -> {
|
||||
url.addQueryParameter("adult", filter.toUriPart())
|
||||
}
|
||||
is GenreConditionFilter -> {
|
||||
url.addQueryParameter("op", filter.toUriPart())
|
||||
}
|
||||
is GenreList -> {
|
||||
filter.state
|
||||
.filter { it.state }
|
||||
.let { list ->
|
||||
if (list.isNotEmpty()) { list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) } }
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
protected open val authorFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Autor"
|
||||
else -> "Author"
|
||||
}
|
||||
|
||||
protected open val artistFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Artista"
|
||||
else -> "Artist"
|
||||
}
|
||||
|
||||
protected open val yearFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Ano de lançamento"
|
||||
else -> "Year of Released"
|
||||
}
|
||||
|
||||
protected open val statusFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Estado"
|
||||
else -> "Status"
|
||||
}
|
||||
|
||||
protected open val statusFilterOptions: Array<String> = when (lang) {
|
||||
"pt-BR" -> arrayOf("Completo", "Em andamento", "Cancelado", "Pausado")
|
||||
else -> arrayOf("Completed", "Ongoing", "Canceled", "On Hold")
|
||||
}
|
||||
|
||||
protected val statusFilterOptionsValues: Array<String> = arrayOf(
|
||||
"end",
|
||||
"on-going",
|
||||
"canceled",
|
||||
"on-hold",
|
||||
)
|
||||
|
||||
protected open val orderByFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Ordenar por"
|
||||
else -> "Order By"
|
||||
}
|
||||
|
||||
protected open val orderByFilterOptions: Array<String> = when (lang) {
|
||||
"pt-BR" -> arrayOf(
|
||||
"Relevância",
|
||||
"Recentes",
|
||||
"A-Z",
|
||||
"Avaliação",
|
||||
"Tendência",
|
||||
"Visualizações",
|
||||
"Novos",
|
||||
)
|
||||
else -> arrayOf(
|
||||
"Relevance",
|
||||
"Latest",
|
||||
"A-Z",
|
||||
"Rating",
|
||||
"Trending",
|
||||
"Most Views",
|
||||
"New",
|
||||
)
|
||||
}
|
||||
|
||||
protected val orderByFilterOptionsValues: Array<String> = arrayOf(
|
||||
"",
|
||||
"latest",
|
||||
"alphabet",
|
||||
"rating",
|
||||
"trending",
|
||||
"views",
|
||||
"new-manga",
|
||||
)
|
||||
|
||||
protected open val genreConditionFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Operador dos gêneros"
|
||||
else -> "Genre condition"
|
||||
}
|
||||
|
||||
protected open val genreConditionFilterOptions: Array<String> = when (lang) {
|
||||
"pt-BR" -> arrayOf("OU", "E")
|
||||
else -> arrayOf("OR", "AND")
|
||||
}
|
||||
|
||||
protected open val adultContentFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Conteúdo adulto"
|
||||
else -> "Adult Content"
|
||||
}
|
||||
|
||||
protected open val adultContentFilterOptions: Array<String> = when (lang) {
|
||||
"pt-BR" -> arrayOf("Indiferente", "Nenhum", "Somente")
|
||||
else -> arrayOf("All", "None", "Only")
|
||||
}
|
||||
|
||||
protected open val genreFilterHeader: String = when (lang) {
|
||||
"pt-BR" -> "O filtro de gêneros pode não funcionar"
|
||||
else -> "Genres filter may not work for all sources"
|
||||
}
|
||||
|
||||
protected open val genreFilterTitle: String = when (lang) {
|
||||
"pt-BR" -> "Gêneros"
|
||||
else -> "Genres"
|
||||
}
|
||||
|
||||
protected open val genresMissingWarning: String = when (lang) {
|
||||
"pt-BR" -> "Aperte 'Redefinir' para tentar mostrar os gêneros"
|
||||
else -> "Press 'Reset' to attempt to show the genres"
|
||||
}
|
||||
|
||||
protected class AuthorFilter(title: String) : Filter.Text(title)
|
||||
protected class ArtistFilter(title: String) : Filter.Text(title)
|
||||
protected class YearFilter(title: String) : Filter.Text(title)
|
||||
protected class StatusFilter(title: String, status: List<Tag>) :
|
||||
Filter.Group<Tag>(title, status)
|
||||
|
||||
protected class OrderByFilter(title: String, options: List<Pair<String, String>>, state: Int = 0) :
|
||||
UriPartFilter(title, options.toTypedArray(), state)
|
||||
|
||||
protected class GenreConditionFilter(title: String, options: Array<String>) : UriPartFilter(
|
||||
title,
|
||||
options.zip(arrayOf("", "1")).toTypedArray(),
|
||||
)
|
||||
|
||||
protected class AdultContentFilter(title: String, options: Array<String>) : UriPartFilter(
|
||||
title,
|
||||
options.zip(arrayOf("", "0", "1")).toTypedArray(),
|
||||
)
|
||||
|
||||
protected class GenreList(title: String, genres: List<Genre>) : Filter.Group<Genre>(title, genres)
|
||||
class Genre(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters = mutableListOf(
|
||||
AuthorFilter(authorFilterTitle),
|
||||
ArtistFilter(artistFilterTitle),
|
||||
YearFilter(yearFilterTitle),
|
||||
StatusFilter(statusFilterTitle, getStatusList()),
|
||||
OrderByFilter(
|
||||
title = orderByFilterTitle,
|
||||
options = orderByFilterOptions.zip(orderByFilterOptionsValues),
|
||||
state = 0,
|
||||
),
|
||||
AdultContentFilter(adultContentFilterTitle, adultContentFilterOptions),
|
||||
)
|
||||
|
||||
if (genresList.isNotEmpty()) {
|
||||
filters += listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header(genreFilterHeader),
|
||||
GenreConditionFilter(genreConditionFilterTitle, genreConditionFilterOptions),
|
||||
GenreList(genreFilterTitle, genresList),
|
||||
)
|
||||
} else if (fetchGenres) {
|
||||
filters += listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header(genresMissingWarning),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
protected fun getStatusList() = statusFilterOptionsValues
|
||||
.zip(statusFilterOptions)
|
||||
.map { Tag(it.first, it.second) }
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>, state: Int = 0) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
open class Tag(val id: String, name: String) : Filter.CheckBox(name)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
runCatching { fetchGenres() }
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.c-tabs-item__content"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
|
||||
with(element) {
|
||||
select("div.post-title a").first()?.let {
|
||||
manga.setUrlWithoutDomain(it.attr("abs:href"))
|
||||
manga.title = it.ownText()
|
||||
}
|
||||
select("img").first()?.let {
|
||||
manga.thumbnail_url = imageFromElement(it)
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector(): String? = "div.nav-previous, nav.navigation-ajax, a.nextpostslink"
|
||||
|
||||
// Manga Details Parse
|
||||
|
||||
protected val completedStatusList: Array<String> = arrayOf(
|
||||
"Completed",
|
||||
"Completo",
|
||||
"Completado",
|
||||
"Concluído",
|
||||
"Concluido",
|
||||
"Finalizado",
|
||||
"Achevé",
|
||||
"Terminé",
|
||||
"Hoàn Thành",
|
||||
"مكتملة",
|
||||
"مكتمل",
|
||||
"已完结",
|
||||
)
|
||||
|
||||
protected val ongoingStatusList: Array<String> = arrayOf(
|
||||
"OnGoing", "Продолжается", "Updating", "Em Lançamento", "Em lançamento", "Em andamento",
|
||||
"Em Andamento", "En cours", "En Cours", "En cours de publication", "Ativo", "Lançando", "Đang Tiến Hành", "Devam Ediyor",
|
||||
"Devam ediyor", "In Corso", "In Arrivo", "مستمرة", "مستمر", "En Curso", "En curso", "Emision",
|
||||
"Curso", "En marcha", "Publicandose", "En emision", "连载中", "Em Lançamento",
|
||||
)
|
||||
|
||||
protected val hiatusStatusList: Array<String> = arrayOf(
|
||||
"On Hold",
|
||||
"Pausado",
|
||||
"En espera",
|
||||
)
|
||||
|
||||
protected val canceledStatusList: Array<String> = arrayOf(
|
||||
"Canceled",
|
||||
"Cancelado",
|
||||
)
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val manga = SManga.create()
|
||||
with(document) {
|
||||
select(mangaDetailsSelectorTitle).first()?.let {
|
||||
manga.title = it.ownText()
|
||||
}
|
||||
select(mangaDetailsSelectorAuthor).eachText().filter {
|
||||
it.notUpdating()
|
||||
}.joinToString().takeIf { it.isNotBlank() }?.let {
|
||||
manga.author = it
|
||||
}
|
||||
select(mangaDetailsSelectorArtist).eachText().filter {
|
||||
it.notUpdating()
|
||||
}.joinToString().takeIf { it.isNotBlank() }?.let {
|
||||
manga.artist = it
|
||||
}
|
||||
select(mangaDetailsSelectorDescription).let {
|
||||
if (it.select("p").text().isNotEmpty()) {
|
||||
manga.description = it.select("p").joinToString(separator = "\n\n") { p ->
|
||||
p.text().replace("<br>", "\n")
|
||||
}
|
||||
} else {
|
||||
manga.description = it.text()
|
||||
}
|
||||
}
|
||||
select(mangaDetailsSelectorThumbnail).first()?.let {
|
||||
manga.thumbnail_url = imageFromElement(it)
|
||||
}
|
||||
select(mangaDetailsSelectorStatus).last()?.let {
|
||||
manga.status = with(it.text()) {
|
||||
when {
|
||||
containsIn(completedStatusList) -> SManga.COMPLETED
|
||||
containsIn(ongoingStatusList) -> SManga.ONGOING
|
||||
containsIn(hiatusStatusList) -> SManga.ON_HIATUS
|
||||
containsIn(canceledStatusList) -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
val genres = select(mangaDetailsSelectorGenre)
|
||||
.map { element -> element.text().lowercase(Locale.ROOT) }
|
||||
.toMutableSet()
|
||||
|
||||
// add tag(s) to genre
|
||||
val mangaTitle = try {
|
||||
manga.title
|
||||
} catch (_: UninitializedPropertyAccessException) {
|
||||
"not initialized"
|
||||
}
|
||||
|
||||
if (mangaDetailsSelectorTag.isNotEmpty()) {
|
||||
select(mangaDetailsSelectorTag).forEach { element ->
|
||||
if (genres.contains(element.text()).not() &&
|
||||
element.text().length <= 25 &&
|
||||
element.text().contains("read", true).not() &&
|
||||
element.text().contains(name, true).not() &&
|
||||
element.text().contains(name.replace(" ", ""), true).not() &&
|
||||
element.text().contains(mangaTitle, true).not() &&
|
||||
element.text().contains(altName, true).not()
|
||||
) {
|
||||
genres.add(element.text().lowercase(Locale.ROOT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add manga/manhwa/manhua thinggy to genre
|
||||
document.select(seriesTypeSelector).firstOrNull()?.ownText()?.let {
|
||||
if (it.isEmpty().not() && it.notUpdating() && it != "-" && genres.contains(it).not()) {
|
||||
genres.add(it.lowercase(Locale.ROOT))
|
||||
}
|
||||
}
|
||||
|
||||
manga.genre = genres.toList().joinToString(", ") { genre ->
|
||||
genre.replaceFirstChar {
|
||||
if (it.isLowerCase()) {
|
||||
it.titlecase(
|
||||
Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
it.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add alternative name to manga description
|
||||
document.select(altNameSelector).firstOrNull()?.ownText()?.let {
|
||||
if (it.isBlank().not() && it.notUpdating()) {
|
||||
manga.description = when {
|
||||
manga.description.isNullOrBlank() -> altName + it
|
||||
else -> manga.description + "\n\n$altName" + it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
// Manga Details Selector
|
||||
open val mangaDetailsSelectorTitle = "div.post-title h3, div.post-title h1"
|
||||
open val mangaDetailsSelectorAuthor = "div.author-content > a"
|
||||
open val mangaDetailsSelectorArtist = "div.artist-content > a"
|
||||
open val mangaDetailsSelectorStatus = "div.summary-content"
|
||||
open val mangaDetailsSelectorDescription = "div.description-summary div.summary__content, div.summary_content div.post-content_item > h5 + div, div.summary_content div.manga-excerpt"
|
||||
open val mangaDetailsSelectorThumbnail = "div.summary_image img"
|
||||
open val mangaDetailsSelectorGenre = "div.genres-content a"
|
||||
open val mangaDetailsSelectorTag = "div.tags-content a"
|
||||
|
||||
open val seriesTypeSelector = ".post-content_item:contains(Type) .summary-content"
|
||||
open val altNameSelector = ".post-content_item:contains(Alt) .summary-content"
|
||||
open val altName = when (lang) {
|
||||
"pt-BR" -> "Nomes alternativos: "
|
||||
else -> "Alternative Names: "
|
||||
}
|
||||
open val updatingRegex = "Updating|Atualizando".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
fun String.notUpdating(): Boolean {
|
||||
return this.contains(updatingRegex).not()
|
||||
}
|
||||
|
||||
fun String.containsIn(array: Array<String>): Boolean {
|
||||
return this.lowercase() in array.map { it.lowercase() }
|
||||
}
|
||||
|
||||
protected open fun imageFromElement(element: Element): String? {
|
||||
return when {
|
||||
element.hasAttr("data-src") -> element.attr("abs:data-src")
|
||||
element.hasAttr("data-lazy-src") -> element.attr("abs:data-lazy-src")
|
||||
element.hasAttr("srcset") -> element.attr("abs:srcset").substringBefore(" ")
|
||||
else -> element.attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set it to true if the source uses the new AJAX endpoint to
|
||||
* fetch the manga chapters instead of the old admin-ajax.php one.
|
||||
*/
|
||||
protected open val useNewChapterEndpoint: Boolean = false
|
||||
|
||||
/**
|
||||
* Internal attribute to control if it should always use the
|
||||
* new chapter endpoint after a first check if useNewChapterEndpoint is
|
||||
* set to false. Using a separate variable to still allow the other
|
||||
* one to be overridable manually in each source.
|
||||
*/
|
||||
private var oldChapterEndpointDisabled: Boolean = false
|
||||
|
||||
protected open fun oldXhrChaptersRequest(mangaId: String): Request {
|
||||
val form = FormBody.Builder()
|
||||
.add("action", "manga_get_chapters")
|
||||
.add("manga", mangaId)
|
||||
.build()
|
||||
|
||||
val xhrHeaders = headersBuilder()
|
||||
.add("Content-Length", form.contentLength().toString())
|
||||
.add("Content-Type", form.contentType().toString())
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
return POST("$baseUrl/wp-admin/admin-ajax.php", xhrHeaders, form)
|
||||
}
|
||||
|
||||
protected open fun xhrChaptersRequest(mangaUrl: String): Request {
|
||||
val xhrHeaders = headersBuilder()
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
return POST("$mangaUrl/ajax/chapters", xhrHeaders)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val chaptersWrapper = document.select("div[id^=manga-chapters-holder]")
|
||||
|
||||
var chapterElements = document.select(chapterListSelector())
|
||||
|
||||
if (chapterElements.isEmpty() && !chaptersWrapper.isNullOrEmpty()) {
|
||||
val mangaUrl = document.location().removeSuffix("/")
|
||||
val mangaId = chaptersWrapper.attr("data-id")
|
||||
|
||||
var xhrRequest = if (useNewChapterEndpoint || oldChapterEndpointDisabled) {
|
||||
xhrChaptersRequest(mangaUrl)
|
||||
} else {
|
||||
oldXhrChaptersRequest(mangaId)
|
||||
}
|
||||
var xhrResponse = client.newCall(xhrRequest).execute()
|
||||
|
||||
// Newer Madara versions throws HTTP 400 when using the old endpoint.
|
||||
if (!useNewChapterEndpoint && xhrResponse.code == 400) {
|
||||
xhrResponse.close()
|
||||
// Set it to true so following calls will be made directly to the new endpoint.
|
||||
oldChapterEndpointDisabled = true
|
||||
|
||||
xhrRequest = xhrChaptersRequest(mangaUrl)
|
||||
xhrResponse = client.newCall(xhrRequest).execute()
|
||||
}
|
||||
|
||||
chapterElements = xhrResponse.asJsoup().select(chapterListSelector())
|
||||
xhrResponse.close()
|
||||
}
|
||||
|
||||
countViews(document)
|
||||
|
||||
return chapterElements.map(::chapterFromElement)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "li.wp-manga-chapter"
|
||||
|
||||
protected open fun chapterDateSelector() = "span.chapter-release-date"
|
||||
|
||||
open val chapterUrlSelector = "a"
|
||||
|
||||
// can cause some issue for some site. blocked by cloudflare when opening the chapter pages
|
||||
open val chapterUrlSuffix = "?style=list"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
|
||||
with(element) {
|
||||
select(chapterUrlSelector).first()?.let { urlElement ->
|
||||
chapter.url = urlElement.attr("abs:href").let {
|
||||
it.substringBefore("?style=paged") + if (!it.endsWith(chapterUrlSuffix)) chapterUrlSuffix else ""
|
||||
}
|
||||
chapter.name = urlElement.text()
|
||||
}
|
||||
// Dates can be part of a "new" graphic or plain text
|
||||
// Added "title" alternative
|
||||
chapter.date_upload = select("img:not(.thumb)").firstOrNull()?.attr("alt")?.let { parseRelativeDate(it) }
|
||||
?: select("span a").firstOrNull()?.attr("title")?.let { parseRelativeDate(it) }
|
||||
?: parseChapterDate(select(chapterDateSelector()).firstOrNull()?.text())
|
||||
}
|
||||
|
||||
return chapter
|
||||
}
|
||||
|
||||
open fun parseChapterDate(date: String?): Long {
|
||||
date ?: return 0
|
||||
|
||||
fun SimpleDateFormat.tryParse(string: String): Long {
|
||||
return try {
|
||||
parse(string)?.time ?: 0
|
||||
} catch (_: ParseException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
// Handle 'yesterday' and 'today', using midnight
|
||||
WordSet("yesterday", "يوم واحد").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("today").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("يومين").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, -2) // day before yesterday
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
WordSet("hace").startsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
|
||||
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
|
||||
date.split(" ").map {
|
||||
if (it.contains(Regex("""\d\D\D"""))) {
|
||||
it.replace(Regex("""\D"""), "")
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.let { dateFormat.tryParse(it.joinToString(" ")) }
|
||||
}
|
||||
else -> dateFormat.tryParse(date)
|
||||
}
|
||||
}
|
||||
|
||||
// Parses dates in this form:
|
||||
// 21 horas ago
|
||||
protected open fun parseRelativeDate(date: String): Long {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "天").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||
WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
WordSet("week", "semana").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
|
||||
WordSet("month", "mes").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||
WordSet("year", "año").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.startsWith("http")) {
|
||||
return GET(chapter.url, headers)
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
countViews(document)
|
||||
|
||||
val chapterProtector = document.selectFirst(chapterProtectorSelector)
|
||||
?: return document.select(pageListParseSelector).mapIndexed { index, element ->
|
||||
val imageUrl = element.selectFirst("img")?.let { imageFromElement(it) }
|
||||
Page(index, document.location(), imageUrl)
|
||||
}
|
||||
val chapterProtectorHtml = chapterProtector.html()
|
||||
val password = chapterProtectorHtml
|
||||
.substringAfter("wpmangaprotectornonce='")
|
||||
.substringBefore("';")
|
||||
val chapterData = json.parseToJsonElement(
|
||||
chapterProtectorHtml
|
||||
.substringAfter("chapter_data='")
|
||||
.substringBefore("';")
|
||||
.replace("\\/", "/"),
|
||||
).jsonObject
|
||||
|
||||
val unsaltedCiphertext = Base64.decode(chapterData["ct"]!!.jsonPrimitive.content, Base64.DEFAULT)
|
||||
val salt = chapterData["s"]!!.jsonPrimitive.content.decodeHex()
|
||||
val ciphertext = SALTED + salt + unsaltedCiphertext
|
||||
|
||||
val rawImgArray = CryptoAES.decrypt(Base64.encodeToString(ciphertext, Base64.DEFAULT), password)
|
||||
val imgArrayString = json.parseToJsonElement(rawImgArray).jsonPrimitive.content
|
||||
val imgArray = json.parseToJsonElement(imgArrayString).jsonArray
|
||||
|
||||
return imgArray.mapIndexed { idx, it ->
|
||||
Page(idx, document.location(), it.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!, headers.newBuilder().set("Referer", page.url).build())
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
/**
|
||||
* Set it to false if you want to disable the extension reporting the view count
|
||||
* back to the source website through admin-ajax.php.
|
||||
*/
|
||||
protected open val sendViewCount: Boolean = true
|
||||
|
||||
protected open fun countViewsRequest(document: Document): Request? {
|
||||
val wpMangaData = document.select("script#wp-manga-js-extra").firstOrNull()
|
||||
?.data() ?: return null
|
||||
|
||||
val wpMangaInfo = wpMangaData
|
||||
.substringAfter("var manga = ")
|
||||
.substringBeforeLast(";")
|
||||
|
||||
val wpManga = runCatching { json.parseToJsonElement(wpMangaInfo).jsonObject }
|
||||
.getOrNull() ?: return null
|
||||
|
||||
if (wpManga["enable_manga_view"]?.jsonPrimitive?.content == "1") {
|
||||
val formBuilder = FormBody.Builder()
|
||||
.add("action", "manga_views")
|
||||
.add("manga", wpManga["manga_id"]!!.jsonPrimitive.content)
|
||||
|
||||
if (wpManga["chapter_slug"] != null) {
|
||||
formBuilder.add("chapter", wpManga["chapter_slug"]!!.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
val formBody = formBuilder.build()
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Content-Length", formBody.contentLength().toString())
|
||||
.set("Content-Type", formBody.contentType().toString())
|
||||
.set("Referer", document.location())
|
||||
.build()
|
||||
|
||||
val ajaxUrl = wpManga["ajax_url"]!!.jsonPrimitive.content
|
||||
|
||||
return POST(ajaxUrl, newHeaders, formBody)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the view count request to the Madara endpoint.
|
||||
*
|
||||
* @param document The response document with the wp-manga data
|
||||
*/
|
||||
protected open fun countViews(document: Document) {
|
||||
if (!sendViewCount) {
|
||||
return
|
||||
}
|
||||
|
||||
val request = countViewsRequest(document) ?: return
|
||||
runCatching { client.newCall(request).execute().close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the genres from the source to be used in the filters.
|
||||
*/
|
||||
protected open fun fetchGenres() {
|
||||
if (fetchGenres && fetchGenresAttempts <= 3 && (genresList.isEmpty() || fetchGenresFailed)) {
|
||||
val genres = runCatching {
|
||||
client.newCall(genresRequest()).execute()
|
||||
.use { parseGenres(it.asJsoup()) }
|
||||
}
|
||||
|
||||
fetchGenresFailed = genres.isFailure
|
||||
genresList = genres.getOrNull().orEmpty()
|
||||
fetchGenresAttempts++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The request to the search page (or another one) that have the genres list.
|
||||
*/
|
||||
protected open fun genresRequest(): Request {
|
||||
return GET("$baseUrl/?s=genre&post_type=wp-manga", headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the genres from the search page document.
|
||||
*
|
||||
* @param document The search page document
|
||||
*/
|
||||
protected open fun parseGenres(document: Document): List<Genre> {
|
||||
return document.selectFirst("div.checkbox-group")
|
||||
?.select("div.checkbox")
|
||||
.orEmpty()
|
||||
.map { li ->
|
||||
Genre(
|
||||
li.selectFirst("label")!!.text(),
|
||||
li.selectFirst("input[type=checkbox]")!!.`val`(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
addRandomUAPreferenceToScreen(screen)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val URL_SEARCH_PREFIX = "slug:"
|
||||
val SALTED = "Salted__".toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
class WordSet(private vararg val words: String) {
|
||||
fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
|
||||
fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) }
|
||||
fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package eu.kanade.tachiyomi.multisrc.madara
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MadaraGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "madara"
|
||||
|
||||
override val themeClass = "Madara"
|
||||
|
||||
override val baseVersionCode: Int = 32
|
||||
|
||||
override val sources = listOf(
|
||||
MultiLang("MangaForFree.net", "https://mangaforfree.net", listOf("en", "ko", "all"), isNsfw = true, className = "MangaForFreeFactory", pkgName = "mangaforfree", overrideVersionCode = 1),
|
||||
MultiLang("Manhwa18.cc", "https://manhwa18.cc", listOf("en", "ko", "all"), isNsfw = true, className = "Manhwa18CcFactory", pkgName = "manhwa18cc", overrideVersionCode = 4),
|
||||
MultiLang("Reaper Scans", "https://reaperscans.com", listOf("fr", "tr"), className = "ReaperScansFactory", pkgName = "reaperscans", overrideVersionCode = 12),
|
||||
SingleLang("1st Kiss-Manga (unoriginal)", "https://1stkiss-manga.com", "en", isNsfw = false, className = "FirstKissDashManga"),
|
||||
SingleLang("1st Manhwa", "https://1stmanhwa.com", "en", isNsfw = true, className = "FirstManhwa"),
|
||||
SingleLang("1stKissManga.blog", "https://1stkissmanga.blog", "en", isNsfw = true, className = "FirstKissMangaBlog"),
|
||||
SingleLang("1stKissManga.Club", "https://1stkissmanga.club", "en", className = "FirstKissMangaClub", overrideVersionCode = 2),
|
||||
SingleLang("1stKissManga.tv", "https://1stkissmanga.tv", "en", isNsfw = true, className = "FirstKissMangaTv"),
|
||||
SingleLang("247Manga", "https://247manga.com", "en", className = "Manga247", overrideVersionCode = 1),
|
||||
SingleLang("365Manga", "https://365manga.com", "en", className = "ThreeSixtyFiveManga", overrideVersionCode = 1),
|
||||
SingleLang("Adonis Fansub", "https://manga.adonisfansub.com", "tr", overrideVersionCode = 1),
|
||||
SingleLang("Adult Webtoon", "https://adultwebtoon.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Akimangá", "https://akimanga.com", "pt-BR", isNsfw = true, className = "Akimanga"),
|
||||
SingleLang("AkuManga", "https://akumanga.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Akuzenai Arts", "https://akuzenaiarts.org", "en"),
|
||||
SingleLang("AllPornComic", "https://allporncomic.com", "en", isNsfw = true),
|
||||
SingleLang("Amuy", "https://apenasmaisumyaoi.com", "pt-BR", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Anikiga", "https://anikiga.com", "tr"),
|
||||
SingleLang("Anisa Manga", "https://anisamanga.com", "tr"),
|
||||
SingleLang("Ansh Scans", "https://anshscans.org", "en", overrideVersionCode = 1),
|
||||
SingleLang("ApollComics", "https://apollcomics.xyz", "es", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Apolltoons", "https://apolltoons.xyz", "es", isNsfw = true),
|
||||
SingleLang("Aqua Manga", "https://aquamanga.org", "en", isNsfw = false, overrideVersionCode = 8),
|
||||
SingleLang("AQUA Scans", "https://aquascans.com", "en", className = "ManhwaWorld", overrideVersionCode = 1),
|
||||
SingleLang("ArazNovel", "https://www.araznovel.com", "tr", overrideVersionCode = 3),
|
||||
SingleLang("Arcanescans", "https://arcanescans.com", "en"),
|
||||
SingleLang("ArcheR Scans", "https://www.archerscans.com", "en", isNsfw = false),
|
||||
SingleLang("Arthur Scan", "https://arthurscan.xyz", "pt-BR", overrideVersionCode = 4),
|
||||
SingleLang("Astral Library", "https://www.astrallibrary.net", "en", overrideVersionCode = 2),
|
||||
SingleLang("Astral-Manga", "https://astral-manga.fr", "fr", className = "AstralManga"),
|
||||
SingleLang("Astrum Scans", "https://astrumscans.xyz", "pt-BR", isNsfw = true),
|
||||
SingleLang("Asura Scans.us (unoriginal)", "https://asurascans.us", "en", isNsfw = false, className = "AsuraScansUs"),
|
||||
SingleLang("Atlantis Scan", "https://scansatlanticos.com", "es", isNsfw = true),
|
||||
SingleLang("AZManhwa", "https://azmanhwa.net", "en"),
|
||||
SingleLang("Azora", "https://azoramoon.com", "ar", isNsfw = false, overrideVersionCode = 7),
|
||||
SingleLang("Babel Wuxia", "https://babelwuxia.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Bakaman", "https://bakaman.net", "th", overrideVersionCode = 1),
|
||||
SingleLang("Banana Manga", "https://bananamanga.net", "en", isNsfw = true),
|
||||
SingleLang("BarManga", "https://barmanga.com", "es"),
|
||||
SingleLang("BestManga", "https://bestmanga.club", "ru", overrideVersionCode = 1),
|
||||
SingleLang("BestManhua", "https://bestmanhua.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("BirdToon", "https://birdtoon.net", "id", isNsfw = true),
|
||||
SingleLang("BlogManga", "https://blogmanga.net", "en"),
|
||||
SingleLang("Blue Solo", "https://www1.bluesolo.org", "fr", isNsfw = true),
|
||||
SingleLang("BokugenTranslation", "https://bokugents.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Boruto Explorer", "https://leitor.borutoexplorer.com.br", "pt-BR", overrideVersionCode = 1),
|
||||
SingleLang("BoysLove", "https://boyslove.me", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Café com Yaoi", "http://cafecomyaoi.com.br", "pt-BR", pkgName = "cafecomyaoi", className = "CafeComYaoi", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("CAT-translator", "https://cats-translator.com/manga", "th", className = "CatTranslator", overrideVersionCode = 2),
|
||||
SingleLang("Cat300", "https://cat300.com", "th", isNsfw = true, className = "Cat300", overrideVersionCode = 1),
|
||||
SingleLang("CatOnHeadTranslations", "https://catonhead.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Cerise Scan", "https://cerisescan.com", "pt-BR", pkgName = "cerisescans", isNsfw = true, overrideVersionCode = 7),
|
||||
SingleLang("Chibi Manga", "https://www.cmreader.info", "en", overrideVersionCode = 1),
|
||||
SingleLang("Clover Manga", "https://clover-manga.com", "tr", overrideVersionCode = 2),
|
||||
SingleLang("Coco Rip", "https://cocorip.net", "es"),
|
||||
SingleLang("Coffee Manga", "https://coffeemanga.io", "en", isNsfw = false, overrideVersionCode = 2),
|
||||
SingleLang("CoffeeManga.top (unoriginal)", "https://coffeemanga.top", "en", isNsfw = true, className = "CoffeeMangaTop"),
|
||||
SingleLang("Colored Manga", "https://coloredmanga.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Comic Scans", "https://www.comicscans.org", "en", isNsfw = false),
|
||||
SingleLang("Comics Valley", "https://comicsvalley.com", "hi", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("ComicsWorld", "https://comicsworld.in", "hi"),
|
||||
SingleLang("Comicz.net v2", "https://v2.comiz.net", "all", isNsfw = true, className = "ComiczNetV2"),
|
||||
SingleLang("Cookie Kiara", "https://18.kiara.cool", "en", isNsfw = true),
|
||||
SingleLang("CopyPasteScan", "https://copypastescan.xyz", "es", overrideVersionCode = 1),
|
||||
SingleLang("CreepyScans", "https://creepyscans.com", "en"),
|
||||
SingleLang("DapRob", "https://daprob.com", "es"),
|
||||
SingleLang("Dark Scans", "https://darkscans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Decadence Scans", "https://reader.decadencescans.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Demon Sect", "https://demonsect.com.br", "pt-BR", pkgName = "prismascans", overrideVersionCode = 4),
|
||||
SingleLang("Dessert Scan", "https://cabaredowatame.site", "pt-BR", isNsfw = true),
|
||||
SingleLang("DiamondFansub", "https://diamondfansub.com", "tr", overrideVersionCode = 1),
|
||||
SingleLang("DokkoManga", "https://dokkomanga.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Doodmanga", "https://www.doodmanga.com", "th"),
|
||||
SingleLang("DoujinHentai", "https://doujinhentai.net", "es", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("DragonTea", "https://dragontea.ink", "en", overrideVersionCode = 3),
|
||||
SingleLang("DragonTranslation.net", "https://dragontranslation.net", "es", isNsfw = true, className = "DragonTranslationNet"),
|
||||
SingleLang("Drake Scans", "https://drakescans.com", "en", overrideVersionCode = 4),
|
||||
SingleLang("Dream Manga", "https://www.swarmmanga.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Drope Scan", "https://dropescan.com", "pt-BR", overrideVersionCode = 4),
|
||||
SingleLang("Elite Manga", "https://www.elitemanga.org", "en", isNsfw = false),
|
||||
SingleLang("Emperor Scan", "https://emperorscan.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Empire Webtoon", "https://webtoonsempireron.com", "ar", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Eromiau", "https://www.eromiau.com", "es", isNsfw = true),
|
||||
SingleLang("Esomanga", "https://esomanga.com", "tr", overrideVersionCode = 1),
|
||||
SingleLang("FactManga", "https://factmanga.com", "en", isNsfw = false),
|
||||
SingleLang("Fay Scans", "https://fayscans.com.br", "pt-BR", overrideVersionCode = 1),
|
||||
SingleLang("Final Scans", "https://finalscans.com", "pt-BR", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Fire Scans", "https://firescans.xyz", "en", overrideVersionCode = 1),
|
||||
SingleLang("Fleur Blanche", "https://fbsquads.com", "pt-BR", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Flex Tape Scans", "https://flextapescans.com", "en", isNsfw = true),
|
||||
SingleLang("Flower Manga", "https://flowermanga.com", "pt-BR"),
|
||||
SingleLang("Fox White", "https://foxwhite.com.br", "pt-BR"),
|
||||
SingleLang("FR-Scan", "https://fr-scan.com", "fr", pkgName = "frdashscan", className = "FRScan", overrideVersionCode = 5),
|
||||
SingleLang("Free Manga", "https://freemanga.me", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Free Manhwa", "https://manhwas.com", "en", isNsfw = false),
|
||||
SingleLang("FreeMangaTop", "https://freemangatop.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("FreeWebtoonCoins", "https://freewebtooncoins.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("GalaxyDegenScans", "https://gdscans.com", "en", overrideVersionCode = 4),
|
||||
SingleLang("Gatemanga", "https://gatemanga.com", "ar", overrideVersionCode = 1),
|
||||
SingleLang("Gekkou Scans", "https://gekkouscans.top", "pt-BR", isNsfw = true, pkgName = "gekkouscan", overrideVersionCode = 1),
|
||||
SingleLang("Ghost Scan", "https://ghostscan.com.br", "pt-BR", isNsfw = true),
|
||||
SingleLang("Girls Love Manga!", "https://glmanga.com", "en", isNsfw = true, className = "GirlsLoveManga"),
|
||||
SingleLang("Glory Manga", "https://glorymanga.com", "tr"),
|
||||
SingleLang("Good Girls Scan", "https://goodgirls.moe", "en", isNsfw = true),
|
||||
SingleLang("Goof Fansub", "https://gooffansub.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Grabber Zone", "https://grabber.zone", "all"),
|
||||
SingleLang("Gri Melek", "https://grimelek.net", "tr", isNsfw = true, className = "Siyahmelek", overrideVersionCode = 4),
|
||||
SingleLang("GuncelManga", "https://guncelmanga.com", "tr", overrideVersionCode = 1),
|
||||
SingleLang("Hades no Fansub Hentai", "https://h.mangareaderpro.com", "es", isNsfw = true),
|
||||
SingleLang("Hades no Fansub", "https://hadesnofansub.com", "es", isNsfw = true, overrideVersionCode = 2, className = "HadesNoFansub"),
|
||||
SingleLang("Harimanga", "https://harimanga.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Hattori Manga", "https://hattorimanga.com", "tr", isNsfw = true),
|
||||
SingleLang("Hayalistic", "https://hayalistic.com", "tr"),
|
||||
SingleLang("Hentai CB", "https://hentaicube.net", "vi", isNsfw = true, overrideVersionCode = 6, pkgName = "hentaicube"),
|
||||
SingleLang("Hentai Manga", "https://hentaimanga.me", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Hentai Teca", "https://hentaiteca.net", "pt-BR", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Hentai-Scantrad", "https://hentai.scantrad-vf.cc", "fr", isNsfw = true, className = "HentaiScantrad", overrideVersionCode = 1),
|
||||
SingleLang("Hentai20", "https://hentai20.io", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Hentai3z", "https://hentai3z.xyz", "en", isNsfw = true),
|
||||
SingleLang("Hentai4Free", "https://hentai4free.net", "en", isNsfw = true),
|
||||
SingleLang("HentaiRead", "https://hentairead.com", "en", isNsfw = true, className = "Hentairead", overrideVersionCode = 3),
|
||||
SingleLang("HentaiWebtoon", "https://hentaiwebtoon.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("HentaiXComic", "https://hentaixcomic.com", "en", isNsfw = true),
|
||||
SingleLang("HentaiXDickgirl", "https://hentaixdickgirl.com", "en", isNsfw = true),
|
||||
SingleLang("HentaiXYuri", "https://hentaixyuri.com", "en", isNsfw = true),
|
||||
SingleLang("HentaiZM", "https://manga.hentaizm.fun", "tr", isNsfw = true),
|
||||
SingleLang("HentaiZone", "https://hentaizone.xyz", "fr", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("HerenScan", "https://herenscan.com", "es"),
|
||||
SingleLang("HipercooL", "https://hipercool.xyz", "pt-BR", isNsfw = true, className = "Hipercool"),
|
||||
SingleLang("Hiperdex", "https://hiperdex.com", "en", isNsfw = true, overrideVersionCode = 11),
|
||||
SingleLang("HistoireDHentai", "https://hhentai.fr", "fr", isNsfw = true),
|
||||
SingleLang("Hizomanga", "https://hizomanga.com", "ar", overrideVersionCode = 1),
|
||||
SingleLang("HM2D", "https://mangadistrict.com/hdoujin", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("hManhwa", "https://hmanhwa.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("HouseMangas", "https://housemangas.com", "es"),
|
||||
SingleLang("Hreads", "https://hreads.net", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("I Love Manhwa", "https://ilovemanhwa.com", "en", isNsfw = true),
|
||||
SingleLang("Illusion Scan", "https://illusionscan.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Immortal Updates", "https://immortalupdates.com", "en", overrideVersionCode = 6),
|
||||
SingleLang("Império Scans", "https://imperioscans.com.br", "pt-BR", className = "ImperioScans", overrideVersionCode = 1),
|
||||
SingleLang("InfraFandub", "https://infrafandub.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Inmortal Scan", "https://manga.mundodrama.site", "es"),
|
||||
SingleLang("InstaManhwa", "https://www.instamanhwa.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("IsekaiScan.com", "https://isekaiscan.com", "en", className = "IsekaiScanCom", overrideVersionCode = 4),
|
||||
SingleLang("IsekaiScan.to (unoriginal)", "https://m.isekaiscan.to", "en", isNsfw = true, pkgName = "isekaiscaneu", className = "IsekaiScanTo", overrideVersionCode = 3),
|
||||
SingleLang("IsekaiScan.top (unoriginal)", "https://isekaiscan.top", "en", pkgName = "isekaiscantop", className = "IsekaiScanTop", overrideVersionCode = 1),
|
||||
SingleLang("IsekaiScanManga (unoriginal)", "https://isekaiscanmanga.com", "en", className = "IsekaiScanManga", overrideVersionCode = 1),
|
||||
SingleLang("Its Your Right Manhua", "https://itsyourightmanhua.com", "en", className = "ItsYourRightManhua", overrideVersionCode = 2),
|
||||
SingleLang("Jiangzaitoon", "https://jiangzaitoon.cc", "tr", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Jimanga", "https://jimanga.com", "en", isNsfw = false),
|
||||
SingleLang("Kakusei Project", "https://kakuseiproject.com.br", "pt-BR"),
|
||||
SingleLang("Kami Sama Explorer", "https://leitor.kamisama.com.br", "pt-BR", overrideVersionCode = 2),
|
||||
SingleLang("Karatcam Scans", "https://karatcam-scans.fr", "fr", isNsfw = true),
|
||||
SingleLang("Kataitake", "https://www.kataitake.fr", "fr", isNsfw = true),
|
||||
SingleLang("KawaScans", "https://kawascans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("KenhuaScan", "https://kenhuav2scan.com", "es"),
|
||||
SingleLang("Kings-Manga", "https://www.kings-manga.co", "th", isNsfw = false, className = "KingsManga"),
|
||||
SingleLang("Kissmanga.in", "https://kissmanga.in", "en", className = "KissmangaIn", overrideVersionCode = 3),
|
||||
SingleLang("KlikManga", "https://klikmanga.id", "id", overrideVersionCode = 2),
|
||||
SingleLang("Koinobori Scan", "https://koinoboriscan.com", "es", isNsfw = true, className = "KoinoboriScan"),
|
||||
SingleLang("Komik Chan", "https://komikchan.com", "en", className = "KomikChan", overrideVersionCode = 1),
|
||||
SingleLang("Komik Gue", "https://komikgue.pro", "id", isNsfw = true),
|
||||
SingleLang("KomikRame", "https://komikra.me", "id"),
|
||||
SingleLang("KSGroupScans", "https://ksgroupscans.com", "en"),
|
||||
SingleLang("Kun Manga", "https://kunmanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Lady Manga", "https://ladymanga.com", "en"),
|
||||
SingleLang("Lala Manga", "https://lalamanga.com", "en", isNsfw = true),
|
||||
SingleLang("Lara Manga", "https://laramanga.love", "en", overrideVersionCode = 1),
|
||||
SingleLang("Last Knight Translation", "https://lkscanlation.com", "es", isNsfw = true, className = "LKScanlation"),
|
||||
SingleLang("Lector Online", "https://lectorunm.life", "es", isNsfw = true, className = "Ikifeng", overrideVersionCode = 1),
|
||||
SingleLang("LectorManga.lat", "https://www.lectormanga.lat", "es", isNsfw = true, pkgName = "lectormangalat", className = "LectorMangaLat"),
|
||||
SingleLang("Ler Yaoi", "https://leryaoi.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Leviatan Scans", "https://lscomic.com", "en", overrideVersionCode = 15),
|
||||
SingleLang("LHTranslation", "https://lhtranslation.net", "en", overrideVersionCode = 1),
|
||||
SingleLang("Lily Manga", "https://lilymanga.net", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Limbo Scan", "https://limboscan.com.br", "pt-BR", isNsfw = true),
|
||||
SingleLang("Link Start Scan", "https://www.linkstartscan.xyz", "pt-BR", isNsfw = true),
|
||||
SingleLang("Lolicon", "https://lolicon.mobi", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Lord Manga", "https://lordmanga.com", "en"),
|
||||
SingleLang("Luffy Manga", "https://luffymanga.com", "en", isNsfw = false),
|
||||
SingleLang("LuxManga", "https://luxmanga.com", "en"),
|
||||
SingleLang("MadaraDex", "https://madaradex.org", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Maid Scan", "https://maidscan.com.br", "pt-BR"),
|
||||
SingleLang("Manga 18h", "https://manga18h.com", "en", isNsfw = true),
|
||||
SingleLang("Manga 18x", "https://manga18x.net", "en", isNsfw = true),
|
||||
SingleLang("Manga Action", "https://mangaaction.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Manga Bee", "https://mangabee.net", "en", isNsfw = true),
|
||||
SingleLang("Manga Bin", "https://mangabin.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Chill", "https://toonchill.com", "en", overrideVersionCode = 7),
|
||||
SingleLang("Manga Crab", "https://mangacrab3.com", "es", overrideVersionCode = 7),
|
||||
SingleLang("Manga District", "https://mangadistrict.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Manga Diyari", "https://manga-diyari.com", "tr", overrideVersionCode = 2),
|
||||
SingleLang("Manga Fenix", "https://manhua-fenix.com", "es", overrideVersionCode = 3),
|
||||
SingleLang("Manga Galaxy", "https://mangagalaxy.me", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Hentai", "https://mangahentai.me", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Manga Keyfi", "https://mangakeyfi.net", "tr"),
|
||||
SingleLang("Manga Kiss", "https://mangakiss.org", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Kitsu", "https://mangakitsu.com", "en", isNsfw = false),
|
||||
SingleLang("Manga Leveling", "https://mangaleveling.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Lord", "https://mangalord.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Mammy", "https://mangamammy.ru", "ru", isNsfw = true),
|
||||
SingleLang("Manga Mitsu", "https://mangamitsu.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Manga Nerds", "https://manganerds.com", "en", isNsfw = false),
|
||||
SingleLang("Manga One Love", "https://mangaonelove.site/", "ru", isNsfw = true),
|
||||
SingleLang("Manga Online Team", "https://mangaonlineteam.com", "en"),
|
||||
SingleLang("Manga Queen", "https://mangaqueen.net", "en"),
|
||||
SingleLang("Manga Queen.com", "https://mangaqueen.com", "en", isNsfw = true, className = "MangaQueenCom"),
|
||||
SingleLang("Manga Queen.online (unoriginal)", "https://mangaqueen.online", "en", isNsfw = true, className = "MangaQueenOnline"),
|
||||
SingleLang("Manga Read", "https://mangaread.co", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Rock Team", "https://mangarockteam.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Rock.team (unoriginal)", "https://mangarock.team", "en", isNsfw = false, className = "MangaRockTeamUnoriginal"),
|
||||
SingleLang("Manga Rocky", "https://mangarocky.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Rose", "https://mangarose.net", "ar"),
|
||||
SingleLang("Manga Starz", "https://mangastarz.org", "ar", overrideVersionCode = 5),
|
||||
SingleLang("Manga Too", "https://mangatoo.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Tx.gg (unoriginal)", "https://mangatx.gg", "en", isNsfw = false, className = "MangaTxGg"),
|
||||
SingleLang("Manga Weebs", "https://mangaweebs.in", "en", overrideVersionCode = 8),
|
||||
SingleLang("Manga Şehri", "https://manga-sehri.com", "tr", className = "MangaSehri", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Manga-1001.com", "https://manga-1001.com", "en", isNsfw = false, className = "MangaDash1001Com"),
|
||||
SingleLang("Manga-fast.com", "https://manga-fast.com", "en", className = "Mangafastcom", overrideVersionCode = 3),
|
||||
SingleLang("Manga-Raw.info (unoriginal)", "https://manga-raw.info", "en", isNsfw = true, className = "MangaRawInfo"),
|
||||
SingleLang("Manga-Scantrad", "https://manga-scantrad.io", "fr", className = "MangaScantrad", overrideVersionCode = 3),
|
||||
SingleLang("Manga-TX", "https://manga-tx.com", "en", className = "Mangatxunoriginal"),
|
||||
SingleLang("Manga18fx", "https://manga18fx.com", "en", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("Manga1st.online", "https://manga1st.online", "en", className = "MangaFirstOnline", overrideVersionCode = 1),
|
||||
SingleLang("Manga347", "https://manga347.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Manga3S", "https://manga3s.com", "en", overrideVersionCode = 4),
|
||||
SingleLang("Manga68", "https://manga68.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaBaz", "https://mangabaz.net", "en"),
|
||||
SingleLang("MangaBob", "https://mangabob.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaCC", "https://mangacc.com", "en"),
|
||||
SingleLang("MangaClash", "https://mangaclash.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("MangaClash.tv (unoriginal)", "https://mangaclash.tv", "en", isNsfw = true, className = "MangaClashTv"),
|
||||
SingleLang("MangaCrazy", "https://mangacrazy.net", "all", isNsfw = true),
|
||||
SingleLang("MangaCultivator", "https://mangacultivator.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("MangaCV", "https://mangacv.com", "en", isNsfw = true),
|
||||
SingleLang("MangaDeemak", "https://mangadeemak.com", "th", overrideVersionCode = 2),
|
||||
SingleLang("MangaDino.top (unoriginal)", "https://mangadino.top", "en", isNsfw = true, className = "MangaDinoTop"),
|
||||
SingleLang("MangaDods", "https://mangadods.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("MangaDol", "https://mangadol.com", "en"),
|
||||
SingleLang("MangaEffect", "https://mangaeffect.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Mangaforfree.com", "https://mangaforfree.com", "en", isNsfw = true, className = "Mangaforfreecom"),
|
||||
SingleLang("MangaFoxFull", "https://mangafoxfull.com", "en"),
|
||||
SingleLang("MangaFreak.online", "https://mangafreak.online", "en", className = "MangaFreakOnline"),
|
||||
SingleLang("MangaGG", "https://mangagg.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("MangaGo Yaoi", "https://mangagoyaoi.com", "en", isNsfw = true),
|
||||
SingleLang("MangaHub.fr", "https://mangahub.fr", "fr", isNsfw = true, className = "MangaHubFr", pkgName = "mangahubfr", overrideVersionCode = 2),
|
||||
SingleLang("MangaHZ", "https://www.mangahz.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("MangaK2", "https://mangak2.com", "en", isNsfw = true),
|
||||
SingleLang("Mangakakalot.io (unoriginal)", "https://mangakakalot.io", "en", isNsfw = true, className = "MangakakalotIo"),
|
||||
SingleLang("Mangakakalot.one (unoriginal)", "https://mangakakalot.one", "en", isNsfw = true, className = "MangakakalotOne"),
|
||||
SingleLang("Mangakik", "https://mangakik.net", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaKing", "https://mangaking.net", "en"),
|
||||
SingleLang("MangaKitsune", "https://mangakitsune.com", "en", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("MangaKL", "https://mangakala.com", "ja"),
|
||||
SingleLang("MangaKomi", "https://mangakomi.io", "en", overrideVersionCode = 5),
|
||||
SingleLang("Mangaland", "https://mangaland.net", "es", isNsfw = true),
|
||||
SingleLang("MangaLionz", "https://mangalionz.org", "ar", overrideVersionCode = 2),
|
||||
SingleLang("MangaManiacs", "https://mangamaniacs.org", "en", isNsfw = true),
|
||||
SingleLang("Manganelo.biz", "https://manganelo.biz", "en", isNsfw = true, className = "ManganeloBiz"),
|
||||
SingleLang("Manganelo.website (unoriginal)", "https://manganelo.website", "en", isNsfw = true, className = "ManganeloWebsite"),
|
||||
SingleLang("MangaOnline.team (unoriginal)", "https://mangaonline.team", "en", isNsfw = false, className = "MangaOnlineTeamUnoriginal"),
|
||||
SingleLang("MangaOwl.blog (unoriginal)", "https://mangaowl.blog", "en", isNsfw = true, className = "MangaOwlBlog"),
|
||||
SingleLang("MangaOwl.io (unoriginal)", "https://mangaowl.io", "en", isNsfw = true, className = "MangaOwlIo"),
|
||||
SingleLang("MangaOwl.one (unoriginal)", "https://mangaowl.one", "en", isNsfw = true, className = "MangaOwlOne"),
|
||||
SingleLang("MangaOwl.us (unoriginal)", "https://mangaowl.us", "en", isNsfw = true, className = "MangaOwlUs"),
|
||||
SingleLang("MangaPT", "https://mangapt.com", "es", isNsfw = true),
|
||||
SingleLang("MangaPure", "https://mangapure.net", "en", isNsfw = true),
|
||||
SingleLang("MangaRabic", "https://mangaarabics.com", "ar", overrideVersionCode = 1),
|
||||
SingleLang("MangaRead.org", "https://www.mangaread.org", "en", className = "MangaReadOrg", overrideVersionCode = 2),
|
||||
SingleLang("MangaRolls", "https://mangarolls.net", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaRosie", "https://mangarosie.in", "en", isNsfw = true),
|
||||
SingleLang("MangaRuby.com", "https://mangaruby.com", "en", isNsfw = true, className = "MangaRubyCom"),
|
||||
SingleLang("Mangaryu", "https://mangaryu.com", "en", isNsfw = true),
|
||||
SingleLang("Mangas No Sekai", "https://mangasnosekai.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Mangas Origines", "https://mangas-origines.xyz", "fr", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("Mangas-Origines.fr", "https://mangas-origines.fr", "fr", className = "MangasOriginesFr"),
|
||||
SingleLang("MangaSco", "https://manhwasco.net", "en", overrideVersionCode = 2),
|
||||
SingleLang("MangaSiro", "https://mangasiro.com", "en", isNsfw = true),
|
||||
SingleLang("MangaSpark", "https://mangaspark.org", "ar", overrideVersionCode = 4),
|
||||
SingleLang("MangaStic", "https://mangastic9.com", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Mangasushi", "https://mangasushi.org", "en", overrideVersionCode = 3),
|
||||
SingleLang("MangaTone", "https://mangatone.com", "en"),
|
||||
SingleLang("MangaTop.site", "https://mangatop.site", "all", isNsfw = true, className = "MangaTopSite"),
|
||||
SingleLang("MangaToRead", "https://mangatoread.com", "en"),
|
||||
SingleLang("MangaTX", "https://mangatx.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaTyrant", "https://mangatyrant.com", "en", isNsfw = false),
|
||||
SingleLang("MangaUpdates.top (unoriginal)", "https://mangaupdates.top", "en", isNsfw = true, className = "MangaUpdatesTop"),
|
||||
SingleLang("MangaUS", "https://mangaus.xyz", "en", overrideVersionCode = 2),
|
||||
SingleLang("MangaVisa", "https://mangavisa.com", "en", pkgName = "mangaboss", className = "MangaVisa", overrideVersionCode = 1),
|
||||
SingleLang("MangaX1", "https://mangax1.com", "en"),
|
||||
SingleLang("Mangaxico", "https://mangaxico.com", "es", isNsfw = true),
|
||||
SingleLang("MangaXP", "https://mangaxp.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("MangaYami", "https://www.mangayami.club", "en", overrideVersionCode = 2),
|
||||
SingleLang("Mangá Nanquim", "https://mangananquim.com", "pt-BR", className = "MangaNanquim"),
|
||||
SingleLang("Manhastro", "https://manhastro.com", "pt-BR"),
|
||||
SingleLang("Manhatic", "https://manhatic.com", "ar", isNsfw = true),
|
||||
SingleLang("Manhua ES", "https://manhuaes.com", "en", overrideVersionCode = 6),
|
||||
SingleLang("Manhua Kiss", "https://manhuakiss.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Manhua Mix", "https://manhuamix.com", "en", className = "Manhuasnet", overrideVersionCode = 3),
|
||||
SingleLang("Manhua Plus", "https://manhuaplus.com", "en", overrideVersionCode = 6),
|
||||
SingleLang("Manhua SY", "https://www.manhuasy.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Manhua Zonghe", "https://manhuazonghe.com", "en", isNsfw = true),
|
||||
SingleLang("ManhuaBox", "https://manhuabox.net", "en", overrideVersionCode = 2),
|
||||
SingleLang("ManhuaChill", "https://manhuachill.com", "en"),
|
||||
SingleLang("ManhuaDex", "https://manhuadex.com", "en", isNsfw = false),
|
||||
SingleLang("ManhuaFast", "https://manhuafast.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("ManhuaFast.net (unoriginal)", "https://manhuafast.net", "en", isNsfw = false, className = "ManhuaFastNet"),
|
||||
SingleLang("Manhuaga", "https://manhuaga.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("ManhuaHot", "https://manhuahot.com", "en"),
|
||||
SingleLang("ManhuaManhwa", "https://manhuamanhwa.com", "en", isNsfw = true),
|
||||
SingleLang("ManhuaManhwa.online", "https://manhuamanhwa.online", "en", isNsfw = false, className = "ManhuaManhwaOnline"),
|
||||
SingleLang("ManhuaScan.info (unoriginal)", "https://manhuascan.info", "en", isNsfw = true, className = "ManhuaScanInfo"),
|
||||
SingleLang("ManhuaUS", "https://manhuaus.com", "en", overrideVersionCode = 5),
|
||||
SingleLang("ManhuaZone", "https://manhuazone.org", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manhwa Raw", "https://manhwaraw.com", "ko", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Manhwa-Latino", "https://manhwa-latino.com", "es", isNsfw = true, className = "ManhwaLatino", overrideVersionCode = 7),
|
||||
SingleLang("Manhwa-raw", "https://manhwa-raw.com", "all", isNsfw = true, className = "ManhwaDashRaw", overrideVersionCode = 1),
|
||||
SingleLang("Manhwa18.app", "https://manhwa18.app", "en", isNsfw = true, className = "Manhwa18app"),
|
||||
SingleLang("Manhwa18.org", "https://manhwa18.org", "en", isNsfw = true, className = "Manhwa18Org", overrideVersionCode = 2),
|
||||
SingleLang("Manhwa2Read", "https://manhwa2read.com", "en", isNsfw = false),
|
||||
SingleLang("Manhwa365", "https://manhwa365.com", "en", isNsfw = true),
|
||||
SingleLang("Manhwa68", "https://manhwa68.com", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("ManhwaBookShelf", "https://manhwabookshelf.com", "en"),
|
||||
SingleLang("ManhwaClan", "https://manhwaclan.com", "en"),
|
||||
SingleLang("Manhwafull", "https://manhwafull.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manhwahentai.me", "https://manhwahentai.me", "en", className = "ManhwahentaiMe", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("ManhwaManhua", "https://manhwamanhua.com", "en", isNsfw = true),
|
||||
SingleLang("ManhwaNew", "https://manhwanew.com", "en", isNsfw = true),
|
||||
SingleLang("Manhwas Men", "https://manhwas.men", "en", className = "ManhwasMen", isNsfw = true),
|
||||
SingleLang("Manhwatop", "https://manhwatop.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("ManhwaZ", "https://manhwaz.com", "en", isNsfw = true),
|
||||
SingleLang("Manhwua.fans", "https://manhwua.fans", "en", isNsfw = true, className = "Manhwuafans"),
|
||||
SingleLang("Mantraz Scan", "https://mantrazscan.com", "es"),
|
||||
SingleLang("ManWe", "https://manwe.pro", "tr", className = "EvaScans", overrideVersionCode = 1),
|
||||
SingleLang("ManyComic", "https://manycomic.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("ManyToon", "https://manytoon.com", "en", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("ManyToon.me", "https://manytoon.me", "en", isNsfw = true, className = "ManyToonMe", overrideVersionCode = 5),
|
||||
SingleLang("ManyToonClub", "https://manytoon.club", "ko", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("MG Komik", "https://mgkomik.id", "id", overrideVersionCode = 11),
|
||||
SingleLang("Midnight Mess Scans", "https://midnightmess.org", "en", isNsfw = true, overrideVersionCode = 6),
|
||||
SingleLang("MidnightManga", "http://midnightmanga.com", "es"),
|
||||
SingleLang("Milftoon", "https://milftoon.xxx", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("MiniTwo Scan", "https://minitwoscan.com", "pt-BR"),
|
||||
SingleLang("MMScans", "https://mm-scans.org", "en", overrideVersionCode = 7),
|
||||
SingleLang("Momo no Hana Scan", "https://momonohanascan.com", "pt-BR", className = "MomoNoHanaScan", overrideVersionCode = 1),
|
||||
SingleLang("MonarcaManga", "https://monarcamanga.com", "es"),
|
||||
SingleLang("Moon Witch In Love", "https://moonwitchinlovescan.com", "pt-BR"),
|
||||
SingleLang("MoonLovers Scan", "https://moonloversscan.com.br", "pt-BR", isNsfw = true),
|
||||
SingleLang("Mortals Groove", "https://mortalsgroove.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("MR Yaoi Fansub", "https://mrbenne.com", "pt-BR", isNsfw = true, className = "MrYaoiFansub", overrideVersionCode = 1),
|
||||
SingleLang("Muctau", "https://bibimanga.com", "en", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("MurimScan", "https://murimscan.run", "en", isNsfw = true),
|
||||
SingleLang("My Manhwa", "https://mymanhwa.net", "en"),
|
||||
SingleLang("Mystical Merries", "https://mysticalmerries.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("NeatManga", "https://neatmanga.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("NekoPost.co (unoriginal)", "https://www.nekopost.co", "th", isNsfw = false, className = "NekoPostCo"),
|
||||
SingleLang("NekoScan", "https://nekoscan.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("NewManhua", "https://newmanhua.com", "en", isNsfw = true),
|
||||
SingleLang("Night Comic", "https://www.nightcomic.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Niji Translations", "https://niji-translations.com", "ar", overrideVersionCode = 1),
|
||||
SingleLang("Nitro Manga", "https://nitromanga.com", "en", className = "NitroScans", overrideVersionCode = 1),
|
||||
SingleLang("No Index Scan", "https://noindexscan.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Noblesse Translations", "https://www.noblessev1.com", "es", overrideVersionCode = 2),
|
||||
SingleLang("Nocturne Summer", "https://nocsummer.com.br", "pt-BR", isNsfw = true),
|
||||
SingleLang("NovelCrow", "https://novelcrow.com", "en", isNsfw = true),
|
||||
SingleLang("NovelMic", "https://novelmic.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Novels Town", "https://novelstown.cyou", "ar"),
|
||||
SingleLang("Oh No Manga", "https://ohnomanga.com", "en", isNsfw = true),
|
||||
SingleLang("OnlyManhwa", "https://onlymanhwa.org", "en", isNsfw = true),
|
||||
SingleLang("Pantheon Scan", "https://pantheon-scan.com", "fr", overrideVersionCode = 1),
|
||||
SingleLang("Paragon Scans", "https://paragonscans.com", "en", isNsfw = true),
|
||||
SingleLang("Passa Mão Scan", "https://passamaoscan.com", "pt-BR", isNsfw = true, className = "PassaMaoScan"),
|
||||
SingleLang("Paw Manga", "https://pawmanga.com", "en", isNsfw = true),
|
||||
SingleLang("Petrotechsociety", "https://www.petrotechsociety.org", "en", isNsfw = true),
|
||||
SingleLang("Pian Manga", "https://pianmanga.me", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Pikiran Wibu", "https://pikiran-wibu.com", "id"),
|
||||
SingleLang("Pink Sea Unicorn", "https://psunicorn.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Pirulito Rosa", "https://pirulitorosa.site", "pt-BR", isNsfw = true),
|
||||
SingleLang("Platinum Crown", "https://platinumscans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("PMScans", "https://rackusreads.com", "en"),
|
||||
SingleLang("Pojok Manga", "https://pojokmanga.net", "id", overrideVersionCode = 5),
|
||||
SingleLang("PoManga", "https://pomanga.com", "en"),
|
||||
SingleLang("Pony Manga", "https://ponymanga.com", "en", isNsfw = true),
|
||||
SingleLang("PornComix", "https://www.porncomixonline.net", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Pornhwa18", "https://pornhwa18.com", "id", isNsfw = true),
|
||||
SingleLang("Pornwha", "https://pornwha.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Portal Yaoi", "https://portalyaoi.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Prisma Hentais", "https://prismahentai.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Projeto Scanlator", "https://projetoscanlator.com", "pt-BR", overrideVersionCode = 3),
|
||||
SingleLang("Ragnarok Scanlation", "https://ragnarokscanlation.com", "es", className = "RagnarokScanlation"),
|
||||
SingleLang("RagnarokScan", "https://ragnarokscan.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Raijin Scans", "https://raijinscans.fr", "fr"),
|
||||
SingleLang("Rainbow Fairy Scan", "https://rainbowfairyscan.com", "pt-BR"),
|
||||
SingleLang("Random Scan", "https://randomscanlators.net", "pt-BR", overrideVersionCode = 6),
|
||||
SingleLang("RawDEX", "https://rawdex.net", "ko", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("ReadAdult", "https://readadult.net", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("ReaderGen", "https://fr.readergen.fr", "fr"),
|
||||
SingleLang("ReadFreeComics", "https://readfreecomics.com", "en"),
|
||||
SingleLang("ReadMangaFree", "https://readmangafree.net", "en", isNsfw = true),
|
||||
SingleLang("ReadManhua", "https://readmanhua.net", "en", overrideVersionCode = 2),
|
||||
SingleLang("Rh2PlusManga", "https://www.rh2plusmanga.com", "th", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("RichtoScan", "https://richtoscan.com", "es"),
|
||||
SingleLang("Rightdark Scan", "https://rightdark-scan.com", "es"),
|
||||
SingleLang("Rio2 Manga", "https://rio2manga.com", "en"),
|
||||
SingleLang("ROG Mangás", "https://rogmangas.com", "pt-BR", pkgName = "mangasoverall", className = "RogMangas", overrideVersionCode = 1),
|
||||
SingleLang("Romantik Manga", "https://romantikmanga.com", "tr"),
|
||||
SingleLang("Rüya Manga", "https://www.ruyamanga.com", "tr", className = "RuyaManga", overrideVersionCode = 1),
|
||||
SingleLang("S2Manga", "https://www.s2manga.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Sagrado Império da Britannia", "https://imperiodabritannia.com", "pt-BR", className = "ImperioDaBritannia"),
|
||||
SingleLang("SamuraiScan", "https://samuraiscan.com", "es", overrideVersionCode = 3),
|
||||
SingleLang("Sawamics", "https://sawamics.com", "en"),
|
||||
SingleLang("ScamberTraslator", "https://scambertraslator.com", "es", overrideVersionCode = 3),
|
||||
SingleLang("Scan Hentai Menu", "https://scan.hentai.menu", "fr", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Scantrad-VF", "https://scantrad-vf.co", "fr", className = "ScantradVF"),
|
||||
SingleLang("Sdl scans", "https://sdlscans.com", "es", className = "SdlScans"),
|
||||
SingleLang("Shadowtrad", "https://shadowtrad.net", "fr"),
|
||||
SingleLang("ShavelProiection", "https://www.shavelproiection.com", "it", true),
|
||||
SingleLang("Shayami", "https://shayami.com", "es"),
|
||||
SingleLang("Shiba Manga", "https://shibamanga.com", "en"),
|
||||
SingleLang("Shield Manga", "https://shieldmanga.io", "en", overrideVersionCode = 3),
|
||||
SingleLang("Shinigami", "https://shinigami.moe", "id", overrideVersionCode = 10),
|
||||
SingleLang("Shooting Star Scans", "https://shootingstarscans.com", "en"),
|
||||
SingleLang("ShoujoHearts", "https://shoujohearts.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Sinensis Scan", "https://sinensisscan.net", "pt-BR", pkgName = "sinensis", overrideVersionCode = 6),
|
||||
SingleLang("SISI GELAP", "https://sigel.asia", "id", overrideVersionCode = 4),
|
||||
SingleLang("SkyManga.xyz", "https://skymanga.xyz", "en", isNsfw = true, className = "SkyMangaXyz"),
|
||||
SingleLang("Sleepy Translations", "https://sleepytranslations.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Solo Leveling", "https://readsololeveling.online", "en"),
|
||||
SingleLang("Sugar Babies", "https://sugarbbscan.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Summanga", "https://summanga.com", "en", isNsfw = true),
|
||||
SingleLang("Sunshine Butterfly Scans", "https://sunshinebutterflyscan.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Sweet Time Scan", "https://sweetscan.net", "pt-BR", overrideVersionCode = 2),
|
||||
SingleLang("Tankou Hentai", "https://tankouhentai.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("TappyToon.Net", "https://tappytoon.net", "en", className = "Tappytoonnet"),
|
||||
SingleLang("Tatakae Scan", "https://tatakaescan.com", "pt-BR", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Taurus Fansub", "https://taurusmanga.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("TeenManhua", "https://teenmanhua.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("The Beginning After The End", "https://www.thebeginningaftertheend.fr", "fr", overrideVersionCode = 1),
|
||||
SingleLang("The Blank Scanlation", "https://theblank.net", "en", className = "TheBlank", isNsfw = true),
|
||||
SingleLang("The Guild", "https://theguildscans.com", "en"),
|
||||
SingleLang("Time Naight", "https://timenaight.com", "tr"),
|
||||
SingleLang("Todaymic", "https://todaymic.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("TonizuToon", "https://tonizutoon.com", "tr", isNsfw = true),
|
||||
SingleLang("ToonChill", "https://toonchill.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("ToonGod", "https://www.toongod.org", "en", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("Toonily", "https://toonily.com", "en", isNsfw = true, overrideVersionCode = 11),
|
||||
SingleLang("Toonily.net", "https://toonily.net", "en", isNsfw = true, className = "Toonilynet", overrideVersionCode = 2),
|
||||
SingleLang("Toonizy", "https://toonizy.com", "en", isNsfw = true),
|
||||
SingleLang("ToonMany", "https://toonmany.com", "en", isNsfw = true),
|
||||
SingleLang("Top Manhua", "https://topmanhua.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("TopReadManhwa", "https://topreadmanhwa.com", "en", isNsfw = true),
|
||||
SingleLang("Tortuga Ceviri", "https://tortuga-ceviri.com", "tr"),
|
||||
SingleLang("Traducciones Moonlight", "https://traduccionesmoonlight.com", "es"),
|
||||
SingleLang("TreeManga", "https://treemanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("TritiniaScans", "https://tritinia.org", "en", overrideVersionCode = 4),
|
||||
SingleLang("Tumangaonline.site", "https://tumangaonline.site", "es", isNsfw = true, className = "TumangaonlineSite", pkgName = "tumangaonlinesite"),
|
||||
SingleLang("Unitoon Oficial", "https://unitoonoficial.com", "es"),
|
||||
SingleLang("Unitoon", "https://lectorunitoon.com", "es"),
|
||||
SingleLang("Valkyrie Scan", "https://valkyriescan.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Ver Manhwas", "https://vermanhwa.es", "es", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("VinManga", "https://vinload.com", "en", isNsfw = true),
|
||||
SingleLang("Wakamics", "https://wakamics.net", "en"),
|
||||
SingleLang("Webdex Scans", "https://webdexscans.com", "en", isNsfw = false),
|
||||
SingleLang("Webtoon City", "https://webtooncity.com", "en", isNsfw = false),
|
||||
SingleLang("Webtoon Hatti", "https://webtoonhatti.net", "tr", isNsfw = false, overrideVersionCode = 2),
|
||||
SingleLang("Webtoon TR", "https://webtoontr.net", "tr", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("WebToonily", "https://webtoonily.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("WebtoonScan", "https://webtoonscan.com", "en", isNsfw = true),
|
||||
SingleLang("WebtoonsTOP", "https://webtoons.top", "en", isNsfw = true),
|
||||
SingleLang("WebtoonUK", "https://webtoon.uk", "en", overrideVersionCode = 2),
|
||||
SingleLang("WebtoonXYZ", "https://www.webtoon.xyz", "en", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Wicked Witch Scan", "https://wickedwitchscan.com", "pt-BR"),
|
||||
SingleLang("Winter Scan", "https://winterscan.com", "pt-BR", overrideVersionCode = 4),
|
||||
SingleLang("Wonderland Scan", "https://wonderlandscan.com", "pt-BR", overrideVersionCode = 3),
|
||||
SingleLang("WoopRead", "https://woopread.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("WorldManhwas", "https://worldmanhwas.zone", "id", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("WuxiaWorld", "https://wuxiaworld.site", "en", overrideVersionCode = 1),
|
||||
SingleLang("YANP Fansub", "https://yanpfansub.com", "pt-BR", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("Yaoi Comics", "https://ycscan.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Yaoi Hentai", "https://yaoihentai.me", "en", isNsfw = true),
|
||||
SingleLang("Yaoi.mobi", "https://yaoi.mobi", "en", isNsfw = true, className = "YaoiManga", pkgName = "yaoimanga", overrideVersionCode = 4),
|
||||
SingleLang("YaoiScan", "https://yaoiscan.com", "en", isNsfw = true),
|
||||
SingleLang("YaoiToon", "https://yaoitoon.com", "en", isNsfw = true),
|
||||
SingleLang("YonaBar", "https://yonabar.com", "ar", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Yuri Verso", "https://yuri.live", "pt-BR", overrideVersionCode = 3),
|
||||
SingleLang("Zandy no Fansub", "https://zandynofansub.aishiteru.org", "en"),
|
||||
SingleLang("ZinChanManga", "https://zinchanmanga.com", "en", isNsfw = true),
|
||||
SingleLang("Zinmanga", "https://zinmanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("ZinManga.top (unoriginal)", "https://zinmanga.top", "en", isNsfw = false, className = "ZinMangaTop"),
|
||||
SingleLang("Zinmanhwa", "https://zinmanhwa.com", "en"),
|
||||
SingleLang("ZuttoManga", "https://zuttomanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Çizgi Roman Arşivi", "https://cizgiromanarsivi.com", "tr", className = "CizgiRomanArsivi"),
|
||||
SingleLang("شبكة كونان العربية", "https://manga.detectiveconanar.com", "ar", className = "DetectiveConanAr", overrideVersionCode = 2),
|
||||
SingleLang("عرب تونز", "https://arabtoons.net", "ar", isNsfw = true, className = "ArabToons"),
|
||||
SingleLang("فالكون مانجا", "https://falconmanga.com", "ar", className = "FalconManga"),
|
||||
SingleLang("كوميك العرب", "https://comicarab.com", "ar", isNsfw = true, className = "ComicArab"),
|
||||
SingleLang("مانجا العاشق", "https://3asq.org", "ar", className = "Manga3asq", overrideVersionCode = 2),
|
||||
SingleLang("مانجا ليك", "https://manga-lek.net", "ar", className = "Mangalek", overrideVersionCode = 4),
|
||||
SingleLang("مانجا ليكس", "https://mangaleks.com", "ar", className = "MangaLeks"),
|
||||
SingleLang("مانجا لينك", "https://mangalink.io", "ar", className = "MangaLinkio", overrideVersionCode = 3),
|
||||
SingleLang("巴卡漫画", "https://bakamh.com", "zh", isNsfw = true, className = "Bakamh"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MadaraGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.kanade.tachiyomi.multisrc.madara
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MadaraUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
if (pathSegments != null && pathSegments.size >= 2) {
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${getSLUG(pathSegments)}")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("MadaraUrl", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("MadaraUrl", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun getSLUG(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 2) {
|
||||
val slug = pathSegments[1]
|
||||
"${Madara.URL_SEARCH_PREFIX}$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package eu.kanade.tachiyomi.multisrc.madtheme
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
abstract class MadTheme(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM dd, yyy", Locale.US),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 1)
|
||||
.build()
|
||||
|
||||
// TODO: better cookie sharing
|
||||
// TODO: don't count cached responses against rate limit
|
||||
private val chapterClient: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 12)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("Referer", "$baseUrl/")
|
||||
}
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
searchMangaRequest(page, "", FilterList(OrderFilter(0)))
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun popularMangaSelector(): String =
|
||||
searchMangaSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga =
|
||||
searchMangaFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? =
|
||||
searchMangaNextPageSelector()
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
searchMangaRequest(page, "", FilterList(OrderFilter(1)))
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesSelector(): String =
|
||||
searchMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||
searchMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? =
|
||||
searchMangaNextPageSelector()
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
filter.state
|
||||
.filter { it.state }
|
||||
.let { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
is StatusFilter -> {
|
||||
url.addQueryParameter("status", filter.toUriPart())
|
||||
}
|
||||
is OrderFilter -> {
|
||||
url.addQueryParameter("sort", filter.toUriPart())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String = ".book-detailed-item"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.select("a").first()!!.attr("abs:href"))
|
||||
title = element.select("a").first()!!.attr("title")
|
||||
description = element.select(".summary").first()?.text()
|
||||
genre = element.select(".genres > *").joinToString { it.text() }
|
||||
thumbnail_url = element.select("img").first()!!.attr("abs:data-src")
|
||||
}
|
||||
|
||||
/*
|
||||
* Only some sites use the next/previous buttons, so instead we check for the next link
|
||||
* after the active one. We use the :not() selector to exclude the optional next button
|
||||
*/
|
||||
override fun searchMangaNextPageSelector(): String? = ".paginator > a.active + a:not([rel=next])"
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
title = document.select(".detail h1").first()!!.text()
|
||||
author = document.select(".detail .meta > p > strong:contains(Authors) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||
genre = document.select(".detail .meta > p > strong:contains(Genres) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||
thumbnail_url = document.select("#cover img").first()!!.attr("abs:data-src")
|
||||
|
||||
val altNames = document.select(".detail h2").first()?.text()
|
||||
?.split(',', ';')
|
||||
?.mapNotNull { it.trim().takeIf { it != title } }
|
||||
?: listOf()
|
||||
|
||||
description = document.select(".summary .content").first()?.text() +
|
||||
(altNames.takeIf { it.isNotEmpty() }?.let { "\n\nAlt name(s): ${it.joinToString()}" } ?: "")
|
||||
|
||||
val statusText = document.select(".detail .meta > p > strong:contains(Status) ~ a").first()!!.text()
|
||||
status = when (statusText.lowercase(Locale.US)) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
// API is heavily rate limited. Use custom client
|
||||
return if (manga.status != SManga.LICENSED) {
|
||||
chapterClient.newCall(chapterListRequest(manga))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
chapterListParse(response)
|
||||
}
|
||||
} else {
|
||||
Observable.error(Exception("Licensed - No chapters to show"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
if (response.code in 200..299) {
|
||||
return super.chapterListParse(response)
|
||||
}
|
||||
|
||||
// Try to show message/error from site
|
||||
response.body.let { body ->
|
||||
json.decodeFromString<JsonObject>(body.string())["message"]
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.let { throw Exception(it) }
|
||||
}
|
||||
|
||||
throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request =
|
||||
GET("$baseUrl/api/manga${manga.url}/chapters?source=detail", headers)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (genresList == null) {
|
||||
genresList = parseGenres(response.asJsoup(response.peekBody(Long.MAX_VALUE).string()))
|
||||
}
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String = "#chapter-list > li"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
// Not using setUrlWithoutDomain() to support external chapters
|
||||
url = element.selectFirst("a")!!
|
||||
.absUrl("href")
|
||||
.removePrefix(baseUrl)
|
||||
|
||||
name = element.select(".chapter-title").first()!!.text()
|
||||
date_upload = parseChapterDate(element.select(".chapter-update").first()?.text())
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val html = document.html()
|
||||
|
||||
if (!html.contains("var mainServer = \"")) {
|
||||
val chapterImagesFromHtml = document.select("#chapter-images img")
|
||||
|
||||
// 17/03/2023: Certain hosts only embed two pages in their "#chapter-images" and leave
|
||||
// the rest to be lazily(?) loaded by javascript. Let's extract `chapImages` and compare
|
||||
// the count against our select query. If both counts are the same, extract the original
|
||||
// images directly from the <img> tags otherwise pick the higher count. (heuristic)
|
||||
// First things first, let's verify `chapImages` actually exists.
|
||||
if (html.contains("var chapImages = '")) {
|
||||
val chapterImagesFromJs = html
|
||||
.substringAfter("var chapImages = '")
|
||||
.substringBefore("'")
|
||||
.split(',')
|
||||
|
||||
// Make sure chapter images we've got from javascript all have a host, otherwise
|
||||
// we've got no choice but to fallback to chapter images from HTML.
|
||||
// TODO: This might need to be solved one day ^
|
||||
if (chapterImagesFromJs.all { e ->
|
||||
e.startsWith("http://") || e.startsWith("https://")
|
||||
}
|
||||
) {
|
||||
// Great, we can use these.
|
||||
if (chapterImagesFromHtml.count() < chapterImagesFromJs.count()) {
|
||||
// Seems like we've hit such a host, let's use the images we've obtained
|
||||
// from the javascript string.
|
||||
return chapterImagesFromJs.mapIndexed { index, path ->
|
||||
Page(index, imageUrl = path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No fancy CDN, all images are available directly in <img> tags (hopefully)
|
||||
return chapterImagesFromHtml.mapIndexed { index, element ->
|
||||
Page(index, imageUrl = element.attr("abs:data-src"))
|
||||
}
|
||||
}
|
||||
|
||||
// While the site may support multiple CDN hosts, we have opted to ignore those
|
||||
val mainServer = html
|
||||
.substringAfter("var mainServer = \"")
|
||||
.substringBefore("\"")
|
||||
val schemePrefix = if (mainServer.startsWith("//")) "https:" else ""
|
||||
|
||||
val chapImages = html
|
||||
.substringAfter("var chapImages = '")
|
||||
.substringBefore("'")
|
||||
.split(',')
|
||||
|
||||
return chapImages.mapIndexed { index, path ->
|
||||
Page(index, imageUrl = "$schemePrefix$mainServer$path")
|
||||
}
|
||||
}
|
||||
|
||||
// Image
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return if (chapter.url.toHttpUrlOrNull() != null) {
|
||||
// External chapter
|
||||
GET(chapter.url, headers)
|
||||
} else {
|
||||
super.pageListRequest(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String =
|
||||
throw UnsupportedOperationException("Not used.")
|
||||
|
||||
// Date logic lifted from Madara
|
||||
private fun parseChapterDate(date: String?): Long {
|
||||
date ?: return 0
|
||||
|
||||
fun SimpleDateFormat.tryParse(string: String): Long {
|
||||
return try {
|
||||
parse(string)?.time ?: 0
|
||||
} catch (_: ParseException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
"ago".endsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
else -> dateFormat.tryParse(date)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
date.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||
date.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
date.contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
date.contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic genres
|
||||
private fun parseGenres(document: Document): List<Genre>? {
|
||||
return document.select(".checkbox-group.genres").first()?.select("label")?.map {
|
||||
Genre(it.select(".radio__label").first()!!.text(), it.select("input").`val`())
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
override fun getFilterList() = FilterList(
|
||||
GenreFilter(getGenreList()),
|
||||
StatusFilter(),
|
||||
OrderFilter(),
|
||||
)
|
||||
|
||||
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
||||
private class Genre(name: String, val id: String) : Filter.CheckBox(name)
|
||||
private var genresList: List<Genre>? = null
|
||||
private fun getGenreList(): List<Genre> {
|
||||
// Filters are fetched immediately once an extension loads
|
||||
// We're only able to get filters after a loading the manga directory, and resetting
|
||||
// the filters is the only thing that seems to reinflate the view
|
||||
return genresList ?: listOf(Genre("Press reset to attempt to fetch genres", ""))
|
||||
}
|
||||
|
||||
class StatusFilter : UriPartFilter(
|
||||
"Status",
|
||||
arrayOf(
|
||||
Pair("All", "all"),
|
||||
Pair("Ongoing", "ongoing"),
|
||||
Pair("Completed", "completed"),
|
||||
),
|
||||
)
|
||||
|
||||
class OrderFilter(state: Int = 0) : UriPartFilter(
|
||||
"Order By",
|
||||
arrayOf(
|
||||
Pair("Views", "views"),
|
||||
Pair("Updated", "updated_at"),
|
||||
Pair("Created", "created_at"),
|
||||
Pair("Name A-Z", "name"),
|
||||
Pair("Rating", "rating"),
|
||||
),
|
||||
state,
|
||||
)
|
||||
|
||||
open class UriPartFilter(
|
||||
displayName: String,
|
||||
private val vals: Array<Pair<String, String>>,
|
||||
state: Int = 0,
|
||||
) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.multisrc.madtheme
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MadThemeGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "madtheme"
|
||||
|
||||
override val themeClass = "MadTheme"
|
||||
|
||||
override val baseVersionCode: Int = 13
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("BeeHentai", "https://beehentai.com", "en", isNsfw = true),
|
||||
SingleLang("MangaBuddy", "https://mangabuddy.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("MangaCute", "https://mangacute.com", "en", isNsfw = true),
|
||||
SingleLang("MangaForest", "https://mangaforest.me", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("MangaPuma", "https://mangapuma.com", "en", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("MangaXYZ", "https://mangaxyz.com", "en", isNsfw = true),
|
||||
SingleLang("Toonily.me", "https://toonily.me", "en", isNsfw = true, className = "ToonilyMe"),
|
||||
SingleLang("TooniTube", "https://toonitube.com", "en", isNsfw = true),
|
||||
SingleLang("TrueManga", "https://truemanga.com", "en", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MadThemeGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangabox
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// Based off of Mangakakalot 1.2.8
|
||||
abstract class MangaBox(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val dateformat: SimpleDateFormat = SimpleDateFormat("MMM-dd-yy", Locale.ENGLISH),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.add("Referer", baseUrl) // for covers
|
||||
|
||||
open val popularUrlPath = "manga_list?type=topview&category=all&state=all&page="
|
||||
|
||||
open val latestUrlPath = "manga_list?type=latest&category=all&state=all&page="
|
||||
|
||||
open val simpleQueryPath = "search/"
|
||||
|
||||
override fun popularMangaSelector() = "div.truyen-list > div.list-truyen-item-wrap"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/$popularUrlPath$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/$latestUrlPath$page", headers)
|
||||
}
|
||||
|
||||
protected fun mangaFromElement(element: Element, urlSelector: String = "h3 a"): SManga {
|
||||
return SManga.create().apply {
|
||||
element.select(urlSelector).first()!!.let {
|
||||
url = it.attr("abs:href").substringAfter(baseUrl) // intentionally not using setUrlWithoutDomain
|
||||
title = it.text()
|
||||
}
|
||||
thumbnail_url = element.select("img").first()!!.attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = mangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = mangaFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector() = "div.group_page, div.group-page a:not([href]) + a:not(:contains(Last))"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return if (query.isNotBlank() && getAdvancedGenreFilters().isEmpty()) {
|
||||
GET("$baseUrl/$simpleQueryPath${normalizeSearchQuery(query)}?page=$page", headers)
|
||||
} else {
|
||||
val url = baseUrl.toHttpUrlOrNull()!!.newBuilder()
|
||||
if (getAdvancedGenreFilters().isNotEmpty()) {
|
||||
url.addPathSegment("advanced_search")
|
||||
url.addQueryParameter("page", page.toString())
|
||||
url.addQueryParameter("keyw", normalizeSearchQuery(query))
|
||||
var genreInclude = ""
|
||||
var genreExclude = ""
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is KeywordFilter -> filter.toUriPart()?.let { url.addQueryParameter("keyt", it) }
|
||||
is SortFilter -> url.addQueryParameter("orby", filter.toUriPart())
|
||||
is StatusFilter -> url.addQueryParameter("sts", filter.toUriPart())
|
||||
is AdvGenreFilter -> {
|
||||
filter.state.forEach { if (it.isIncluded()) genreInclude += "_${it.id}" }
|
||||
filter.state.forEach { if (it.isExcluded()) genreExclude += "_${it.id}" }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
url.addQueryParameter("g_i", genreInclude)
|
||||
url.addQueryParameter("g_e", genreExclude)
|
||||
} else {
|
||||
url.addPathSegment("manga_list")
|
||||
url.addQueryParameter("page", page.toString())
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SortFilter -> url.addQueryParameter("type", filter.toUriPart())
|
||||
is StatusFilter -> url.addQueryParameter("state", filter.toUriPart())
|
||||
is GenreFilter -> url.addQueryParameter("category", filter.toUriPart())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET(url.toString(), headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".panel_story_list .story_item"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = "a.page_select + a:not(.page_last), a.page-select + a:not(.page-last)"
|
||||
|
||||
open val mangaDetailsMainSelector = "div.manga-info-top, div.panel-story-info"
|
||||
|
||||
open val thumbnailSelector = "div.manga-info-pic img, span.info-image img"
|
||||
|
||||
open val descriptionSelector = "div#noidungm, div#panel-story-info-description"
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
if (manga.url.startsWith("http")) {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
return super.mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
private fun checkForRedirectMessage(document: Document) {
|
||||
if (document.select("body").text().startsWith("REDIRECT :")) {
|
||||
throw Exception("Source URL has changed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
return SManga.create().apply {
|
||||
document.select(mangaDetailsMainSelector).firstOrNull()?.let { infoElement ->
|
||||
title = infoElement.select("h1, h2").first()!!.text()
|
||||
author = infoElement.select("li:contains(author) a, td:containsOwn(author) + td a").eachText().joinToString()
|
||||
status = parseStatus(infoElement.select("li:contains(status), td:containsOwn(status) + td").text())
|
||||
genre = infoElement.select("div.manga-info-top li:contains(genres)").firstOrNull()
|
||||
?.select("a")?.joinToString { it.text() } // kakalot
|
||||
?: infoElement.select("td:containsOwn(genres) + td a").joinToString { it.text() } // nelo
|
||||
} ?: checkForRedirectMessage(document)
|
||||
description = document.select(descriptionSelector).firstOrNull()?.ownText()
|
||||
?.replace("""^$title summary:\s""".toRegex(), "")
|
||||
?.replace("""<\s*br\s*/?>""".toRegex(), "\n")
|
||||
?.replace("<[^>]*>".toRegex(), "")
|
||||
thumbnail_url = document.select(thumbnailSelector).attr("abs:src")
|
||||
|
||||
// add alternative name to manga description
|
||||
document.select(altNameSelector).firstOrNull()?.ownText()?.let {
|
||||
if (it.isBlank().not()) {
|
||||
description = when {
|
||||
description.isNullOrBlank() -> altName + it
|
||||
else -> description + "\n\n$altName" + it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open val altNameSelector = ".story-alternative, tr:has(.info-alternative) h2"
|
||||
open val altName = "Alternative Name" + ": "
|
||||
|
||||
private fun parseStatus(status: String?) = when {
|
||||
status == null -> SManga.UNKNOWN
|
||||
status.contains("Ongoing") -> SManga.ONGOING
|
||||
status.contains("Completed") -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
if (manga.url.startsWith("http")) {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
return super.chapterListRequest(manga)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select(chapterListSelector())
|
||||
.map { chapterFromElement(it) }
|
||||
.also { if (it.isEmpty()) checkForRedirectMessage(document) }
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.chapter-list div.row, ul.row-content-chapter li"
|
||||
|
||||
protected open val alternateChapterDateSelector = String()
|
||||
|
||||
private fun Element.selectDateFromElement(): Element {
|
||||
val defaultChapterDateSelector = "span"
|
||||
return this.select(defaultChapterDateSelector).lastOrNull() ?: this.select(alternateChapterDateSelector).last()!!
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
return SChapter.create().apply {
|
||||
element.select("a").let {
|
||||
url = it.attr("abs:href").substringAfter(baseUrl) // intentionally not using setUrlWithoutDomain
|
||||
name = it.text()
|
||||
scanlator =
|
||||
it.attr("abs:href").toHttpUrlOrNull()!!.host // show where chapters are actually from
|
||||
}
|
||||
date_upload = parseChapterDate(element.selectDateFromElement().text(), scanlator!!) ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String, host: String): Long? {
|
||||
return if ("ago" in date) {
|
||||
val value = date.split(' ')[0].toIntOrNull()
|
||||
val cal = Calendar.getInstance()
|
||||
when {
|
||||
value != null && "min" in date -> cal.apply { add(Calendar.MINUTE, value * -1) }
|
||||
value != null && "hour" in date -> cal.apply { add(Calendar.HOUR_OF_DAY, value * -1) }
|
||||
value != null && "day" in date -> cal.apply { add(Calendar.DATE, value * -1) }
|
||||
else -> null
|
||||
}?.timeInMillis
|
||||
} else {
|
||||
try {
|
||||
if (host.contains("manganato", ignoreCase = true)) {
|
||||
// Nelo's date format
|
||||
SimpleDateFormat("MMM dd,yy", Locale.ENGLISH).parse(date)
|
||||
} else {
|
||||
dateformat.parse(date)
|
||||
}
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
}?.time
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.startsWith("http")) {
|
||||
return GET(chapter.url, headers)
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
open val pageListSelector = "div#vungdoc img, div.container-chapter-reader img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(pageListSelector)
|
||||
// filter out bad elements for mangakakalots
|
||||
.filterNot { it.attr("src").endsWith("log") }
|
||||
.mapIndexed { i, element ->
|
||||
val url = element.attr("abs:src").let { src ->
|
||||
if (src.startsWith("https://convert_image_digi.mgicdn.com")) {
|
||||
"https://images.weserv.nl/?url=" + src.substringAfter("//")
|
||||
} else {
|
||||
src
|
||||
}
|
||||
}
|
||||
Page(i, document.location(), url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!, headersBuilder().set("Referer", page.url).build())
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Based on change_alias JS function from Mangakakalot's website
|
||||
@SuppressLint("DefaultLocale")
|
||||
open fun normalizeSearchQuery(query: String): String {
|
||||
var str = query.lowercase()
|
||||
str = str.replace("[àáạảãâầấậẩẫăằắặẳẵ]".toRegex(), "a")
|
||||
str = str.replace("[èéẹẻẽêềếệểễ]".toRegex(), "e")
|
||||
str = str.replace("[ìíịỉĩ]".toRegex(), "i")
|
||||
str = str.replace("[òóọỏõôồốộổỗơờớợởỡ]".toRegex(), "o")
|
||||
str = str.replace("[ùúụủũưừứựửữ]".toRegex(), "u")
|
||||
str = str.replace("[ỳýỵỷỹ]".toRegex(), "y")
|
||||
str = str.replace("đ".toRegex(), "d")
|
||||
str = str.replace("""!|@|%|\^|\*|\(|\)|\+|=|<|>|\?|/|,|\.|:|;|'| |"|&|#|\[|]|~|-|$|_""".toRegex(), "_")
|
||||
str = str.replace("_+_".toRegex(), "_")
|
||||
str = str.replace("""^_+|_+$""".toRegex(), "")
|
||||
return str
|
||||
}
|
||||
|
||||
override fun getFilterList() = if (getAdvancedGenreFilters().isNotEmpty()) {
|
||||
FilterList(
|
||||
KeywordFilter(getKeywordFilters()),
|
||||
SortFilter(getSortFilters()),
|
||||
StatusFilter(getStatusFilters()),
|
||||
AdvGenreFilter(getAdvancedGenreFilters()),
|
||||
)
|
||||
} else {
|
||||
FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
SortFilter(getSortFilters()),
|
||||
StatusFilter(getStatusFilters()),
|
||||
GenreFilter(getGenreFilters()),
|
||||
)
|
||||
}
|
||||
|
||||
private class KeywordFilter(vals: Array<Pair<String?, String>>) : UriPartFilter("Keyword search ", vals)
|
||||
private class SortFilter(vals: Array<Pair<String?, String>>) : UriPartFilter("Order by", vals)
|
||||
private class StatusFilter(vals: Array<Pair<String?, String>>) : UriPartFilter("Status", vals)
|
||||
private class GenreFilter(vals: Array<Pair<String?, String>>) : UriPartFilter("Category", vals)
|
||||
|
||||
// For advanced search, specifically tri-state genres
|
||||
private class AdvGenreFilter(vals: List<AdvGenre>) : Filter.Group<AdvGenre>("Category", vals)
|
||||
class AdvGenre(val id: String?, name: String) : Filter.TriState(name)
|
||||
|
||||
// keyt query parameter
|
||||
private fun getKeywordFilters(): Array<Pair<String?, String>> = arrayOf(
|
||||
Pair(null, "Everything"),
|
||||
Pair("title", "Title"),
|
||||
Pair("alternative", "Alt title"),
|
||||
Pair("author", "Author"),
|
||||
)
|
||||
|
||||
private fun getSortFilters(): Array<Pair<String?, String>> = arrayOf(
|
||||
Pair("latest", "Latest"),
|
||||
Pair("newest", "Newest"),
|
||||
Pair("topview", "Top read"),
|
||||
)
|
||||
|
||||
open fun getStatusFilters(): Array<Pair<String?, String>> = arrayOf(
|
||||
Pair("all", "ALL"),
|
||||
Pair("completed", "Completed"),
|
||||
Pair("ongoing", "Ongoing"),
|
||||
Pair("drop", "Dropped"),
|
||||
)
|
||||
|
||||
open fun getGenreFilters(): Array<Pair<String?, String>> = arrayOf(
|
||||
Pair("all", "ALL"),
|
||||
Pair("2", "Action"),
|
||||
Pair("3", "Adult"),
|
||||
Pair("4", "Adventure"),
|
||||
Pair("6", "Comedy"),
|
||||
Pair("7", "Cooking"),
|
||||
Pair("9", "Doujinshi"),
|
||||
Pair("10", "Drama"),
|
||||
Pair("11", "Ecchi"),
|
||||
Pair("12", "Fantasy"),
|
||||
Pair("13", "Gender bender"),
|
||||
Pair("14", "Harem"),
|
||||
Pair("15", "Historical"),
|
||||
Pair("16", "Horror"),
|
||||
Pair("45", "Isekai"),
|
||||
Pair("17", "Josei"),
|
||||
Pair("44", "Manhua"),
|
||||
Pair("43", "Manhwa"),
|
||||
Pair("19", "Martial arts"),
|
||||
Pair("20", "Mature"),
|
||||
Pair("21", "Mecha"),
|
||||
Pair("22", "Medical"),
|
||||
Pair("24", "Mystery"),
|
||||
Pair("25", "One shot"),
|
||||
Pair("26", "Psychological"),
|
||||
Pair("27", "Romance"),
|
||||
Pair("28", "School life"),
|
||||
Pair("29", "Sci fi"),
|
||||
Pair("30", "Seinen"),
|
||||
Pair("31", "Shoujo"),
|
||||
Pair("32", "Shoujo ai"),
|
||||
Pair("33", "Shounen"),
|
||||
Pair("34", "Shounen ai"),
|
||||
Pair("35", "Slice of life"),
|
||||
Pair("36", "Smut"),
|
||||
Pair("37", "Sports"),
|
||||
Pair("38", "Supernatural"),
|
||||
Pair("39", "Tragedy"),
|
||||
Pair("40", "Webtoons"),
|
||||
Pair("41", "Yaoi"),
|
||||
Pair("42", "Yuri"),
|
||||
)
|
||||
|
||||
// To be overridden if using tri-state genres
|
||||
protected open fun getAdvancedGenreFilters(): List<AdvGenre> = emptyList()
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String?, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangabox
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaBoxGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangabox"
|
||||
|
||||
override val themeClass = "MangaBox"
|
||||
|
||||
override val baseVersionCode: Int = 5
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Mangakakalot", "https://mangakakalot.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Manganato", "https://manganato.com", "en", overrideVersionCode = 2, pkgName = "manganelo"),
|
||||
SingleLang("Mangabat", "https://m.mangabat.com", "en", overrideVersionCode = 4),
|
||||
SingleLang("Mangakakalots (unoriginal)", "https://mangakakalots.com", "en", overrideVersionCode = 1, className = "Mangakakalots", pkgName = "mangakakalots"),
|
||||
SingleLang("Mangairo", "https://h.mangairo.com", "en", isNsfw = true, overrideVersionCode = 4),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaBoxGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# MangaBox
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
|
||||
[Uncomment this if needed; and replace ( and ) with ( and )]: <> (- [Guides](#Guides))
|
||||
|
||||
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
|
||||
|
||||
## FAQ
|
||||
|
||||
#### What do `Page list is empty` and `Source URL has changed` mean?
|
||||
The former **Mangabox** extensions have created new entries for many of the manga on their websites. The old entries are obsolete and will not work. To resolve this, [migrate](/help/guides/source-migration/) the manga from the source to itself to get the new entry, or better yet, to a different source entirely to avoid similar errors in the future.
|
||||
|
||||
[Uncomment this if needed]: <> (## Guides)
|
||||
@@ -0,0 +1,110 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangacatalog
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
// Based On the original manga maniac source
|
||||
// MangaCatalog is a network of sites for single franshise sites
|
||||
|
||||
abstract class MangaCatalog(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
open val sourceList = listOf(
|
||||
Pair("$name", "$baseUrl"),
|
||||
).sortedBy { it.first }.distinctBy { it.second }
|
||||
|
||||
// Info
|
||||
|
||||
override val supportsLatest: Boolean = false
|
||||
|
||||
// Popular
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return Observable.just(MangasPage(sourceList.map { popularMangaFromPair(it.first, it.second) }, false))
|
||||
}
|
||||
private fun popularMangaFromPair(name: String, sourceurl: String): SManga = SManga.create().apply {
|
||||
title = name
|
||||
url = sourceurl
|
||||
}
|
||||
override fun popularMangaRequest(page: Int): Request = throw Exception("Not used")
|
||||
override fun popularMangaNextPageSelector(): String? = throw Exception("Not used")
|
||||
override fun popularMangaSelector(): String = throw Exception("Not used")
|
||||
override fun popularMangaFromElement(element: Element) = throw Exception("Not used")
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
|
||||
override fun latestUpdatesNextPageSelector(): String? = throw Exception("Not used")
|
||||
override fun latestUpdatesSelector(): String = throw Exception("Not used")
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = throw Exception("Not used")
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
val mangas = mutableListOf<SManga>()
|
||||
sourceList.map {
|
||||
if (it.first.contains(query)) {
|
||||
mangas.add(popularMangaFromPair(it.first, it.second))
|
||||
}
|
||||
}
|
||||
return Observable.just(MangasPage(mangas, false))
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("Not used")
|
||||
override fun searchMangaNextPageSelector() = throw Exception("Not used")
|
||||
override fun searchMangaSelector() = throw Exception("Not used")
|
||||
override fun searchMangaFromElement(element: Element) = throw Exception("Not used")
|
||||
|
||||
// Get Override
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(chapter.url, headers)
|
||||
}
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
val info = document.select("div.bg-bg-secondary > div.px-6 > div.flex-col").text()
|
||||
title = document.select("div.container > h1").text()
|
||||
description = if ("Description" in info) info.substringAfter("Description").trim() else info
|
||||
thumbnail_url = document.select("div.flex > img").attr("src")
|
||||
}
|
||||
// Chapters
|
||||
|
||||
override fun chapterListSelector(): String = "div.w-full > div.bg-bg-secondary > div.grid"
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
val name1 = element.select(".col-span-4 > a").text()
|
||||
val name2 = element.select(".text-xs:not(a)").text()
|
||||
if (name2 == "") {
|
||||
name = name1
|
||||
} else {
|
||||
name = "$name1 - $name2"
|
||||
}
|
||||
url = element.select(".col-span-4 > a").attr("abs:href")
|
||||
date_upload = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> =
|
||||
document.select(".js-pages-container img.js-page,.img_container img").mapIndexed { index, img ->
|
||||
Page(index, "", img.attr("src"))
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw Exception("Not Used")
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangacatalog
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaCatalogGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangacatalog"
|
||||
|
||||
override val themeClass = "MangaCatalog"
|
||||
|
||||
override val baseVersionCode: Int = 4
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Read Attack on Titan Shingeki no Kyojin Manga", "https://ww8.readsnk.com", "en", className = "ReadAttackOnTitanShingekiNoKyojinManga", overrideVersionCode = 4),
|
||||
SingleLang("Read Berserk Manga", "https://readberserk.com", "en"),
|
||||
SingleLang("Read Black Clover Manga Online", "https://ww7.readblackclover.com", "en"),
|
||||
SingleLang("Read Boku no Hero Academia My Hero Academia Manga", "https://ww6.readmha.com", "en", className = "ReadBokuNoHeroAcademiaMyHeroAcademiaManga", overrideVersionCode = 2),
|
||||
SingleLang("Read Chainsaw Man Manga Online", "https://ww1.readchainsawman.com", "en"),
|
||||
SingleLang("Read Dr. Stone Manga Online", "https://ww3.readdrstone.com", "en", className = "ReadDrStoneMangaOnline"),
|
||||
SingleLang("Read Dragon Ball Super Chou Manga Online", "https://ww6.dbsmanga.com", "en", className = "ReadDragonBallSuperChouMangaOnline", overrideVersionCode = 1),
|
||||
SingleLang("Read Fairy Tail & Edens Zero Manga Online", "https://ww4.readfairytail.com", "en", className = "ReadFairyTailEdensZeroMangaOnline", overrideVersionCode = 1),
|
||||
SingleLang("Read Goblin Slayer Manga Online", "https://manga.watchgoblinslayer.com", "en"),
|
||||
SingleLang("Read Haikyuu!! Manga Online", "https://ww6.readhaikyuu.com", "en", className = "ReadHaikyuuMangaOnline"),
|
||||
SingleLang("Read Hunter x Hunter Manga Online", "https://ww2.readhxh.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Jujutsu Kaisen Manga Online", "https://ww1.readjujutsukaisen.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Kaguya-sama Manga Online", "https://ww1.readkaguyasama.com", "en", className = "ReadKaguyaSamaMangaOnline", overrideVersionCode = 1),
|
||||
SingleLang("Read Kingdom Manga Online", "https://ww2.readkingdom.com", "en"),
|
||||
SingleLang("Read Nanatsu no Taizai 7 Deadly Sins Manga Online", "https://ww3.read7deadlysins.com", "en", className = "ReadNanatsuNoTaizai7DeadlySinsMangaOnline", overrideVersionCode = 2),
|
||||
SingleLang("Read Naruto Boruto Samurai 8 Manga Online", "https://ww7.readnaruto.com", "en", className = "ReadNarutoBorutoSamurai8MangaOnline", overrideVersionCode = 1),
|
||||
SingleLang("Read Noblesse Manhwa Online", "https://ww2.readnoblesse.com", "en"),
|
||||
SingleLang("Read One Piece Manga Online", "https://ww8.readonepiece.com", "en"),
|
||||
SingleLang("Read One-Punch Man Manga Online", "https://ww3.readopm.com", "en", className = "ReadOnePunchManMangaOnlineTwo", pkgName = "readonepunchmanmangaonlinetwo", overrideVersionCode = 1), // exact same name as the one in mangamainac extension
|
||||
SingleLang("Read Solo Leveling Manga Manhwa Online", "https://readsololeveling.org", "en", className = "ReadSoloLevelingMangaManhwaOnline", overrideVersionCode = 2),
|
||||
SingleLang("Read Sword Art Online Manga Online", "https://manga.watchsao.tv", "en"),
|
||||
SingleLang("Read The Promised Neverland Manga Online", "https://ww3.readneverland.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Tokyo Ghoul Re & Tokyo Ghoul Manga Online", "https://ww8.tokyoghoulre.com", "en", className = "ReadTokyoGhoulReTokyoGhoulMangaOnline", overrideVersionCode = 1),
|
||||
SingleLang("Read Tower of God Manhwa Manga Online", "https://ww1.readtowerofgod.com", "en", className = "ReadTowerOfGodManhwaMangaOnline", overrideVersionCode = 2),
|
||||
SingleLang("Read Vinland Saga Manga Online", "https://ww1.readvinlandsaga.com", "en"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaCatalogGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangadventure
|
||||
|
||||
import android.os.Build.VERSION
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import eu.kanade.tachiyomi.source.model.Page as SPage
|
||||
|
||||
/** MangAdventure base source. */
|
||||
abstract class MangAdventure(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "en",
|
||||
) : HttpSource() {
|
||||
/** The site's manga categories. */
|
||||
protected open val categories = DEFAULT_CATEGORIES
|
||||
|
||||
/** The site's manga status names. */
|
||||
protected open val statuses = arrayOf("Any", "Completed", "Ongoing")
|
||||
|
||||
/** The site's sort order labels that correspond to [SortOrder.values]. */
|
||||
protected open val orders = arrayOf(
|
||||
"Title",
|
||||
"Views",
|
||||
"Latest upload",
|
||||
"Chapter count",
|
||||
)
|
||||
|
||||
/** A user agent representing Tachiyomi. */
|
||||
private val userAgent = "Mozilla/5.0 " +
|
||||
"(Android ${VERSION.RELEASE}; Mobile) " +
|
||||
"Tachiyomi/${AppInfo.getVersionName()}"
|
||||
|
||||
/** The URL of the site's API. */
|
||||
private val apiUrl by lazy { "$baseUrl/api/v2" }
|
||||
|
||||
/** The JSON parser of the class. */
|
||||
private val json by injectLazy<Json>()
|
||||
|
||||
override val versionId = 3
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun headersBuilder() =
|
||||
super.headersBuilder().set("User-Agent", userAgent)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
GET("$apiUrl/series?page=$page&sort=-latest_upload", headers)
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
GET("$apiUrl/series?page=$page&sort=-views", headers)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
apiUrl.toHttpUrl().newBuilder().addEncodedPathSegment("series").run {
|
||||
if (query.startsWith(SLUG_QUERY)) {
|
||||
addQueryParameter("slug", query.substring(SLUG_QUERY.length))
|
||||
} else {
|
||||
addQueryParameter("page", page.toString())
|
||||
addQueryParameter("title", query)
|
||||
filters.filterIsInstance<UriFilter>().forEach {
|
||||
addQueryParameter(it.param, it.toString())
|
||||
}
|
||||
}
|
||||
GET(build(), headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
GET("$apiUrl/series/${manga.url}", headers)
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
GET("$apiUrl/series/${manga.url}/chapters?date_format=timestamp", headers)
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) =
|
||||
GET("$apiUrl/chapters/${chapter.url}/pages?track=true", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
response.decode<Paginator<Series>>().let {
|
||||
MangasPage(it.map(::mangaFromJSON), !it.last)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) =
|
||||
latestUpdatesParse(response)
|
||||
|
||||
override fun popularMangaParse(response: Response) =
|
||||
latestUpdatesParse(response)
|
||||
|
||||
override fun chapterListParse(response: Response) =
|
||||
response.decode<Results<Chapter>>().map { chapter ->
|
||||
SChapter.create().apply {
|
||||
url = chapter.id.toString()
|
||||
name = buildString {
|
||||
append(chapter.full_title)
|
||||
if (chapter.final) append(" [END]")
|
||||
}
|
||||
chapter_number = chapter.number
|
||||
date_upload = chapter.published.toLong()
|
||||
scanlator = chapter.groups.joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
response.decode<Series>().let(::mangaFromJSON)
|
||||
|
||||
override fun pageListParse(response: Response) =
|
||||
response.decode<Results<Page>>().map { page ->
|
||||
SPage(page.number, page.url, page.image)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used!")
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = "$baseUrl/reader/${manga.url}"
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = "$apiUrl/chapters/${chapter.url}/read"
|
||||
|
||||
override fun getFilterList() =
|
||||
FilterList(
|
||||
Author(),
|
||||
Artist(),
|
||||
SortOrder(orders),
|
||||
Status(statuses),
|
||||
CategoryList(categories),
|
||||
)
|
||||
|
||||
/** Decodes the JSON response as an object. */
|
||||
private inline fun <reified T> Response.decode() =
|
||||
json.decodeFromJsonElement<T>(json.parseToJsonElement(body.string()))
|
||||
|
||||
/** Converts a [Series] object to an [SManga]. */
|
||||
private fun mangaFromJSON(series: Series) =
|
||||
SManga.create().apply {
|
||||
url = series.slug
|
||||
title = series.title
|
||||
thumbnail_url = series.cover
|
||||
description = buildString {
|
||||
series.description?.let(::append)
|
||||
series.aliases.let {
|
||||
if (!it.isNullOrEmpty()) {
|
||||
it.joinTo(this, "\n", "\n\nAlternative titles:\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
author = series.authors?.joinToString()
|
||||
artist = series.artists?.joinToString()
|
||||
genre = series.categories?.joinToString()
|
||||
status = if (series.licensed == true) {
|
||||
SManga.LICENSED
|
||||
} else {
|
||||
when (series.status) {
|
||||
"completed" -> SManga.COMPLETED
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"hiatus" -> SManga.ON_HIATUS
|
||||
"canceled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Manga categories from MangAdventure `categories.xml` fixture. */
|
||||
val DEFAULT_CATEGORIES = listOf(
|
||||
"4-Koma",
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Doujinshi",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Gender Bender",
|
||||
"Harem",
|
||||
"Hentai",
|
||||
"Historical",
|
||||
"Horror",
|
||||
"Josei",
|
||||
"Martial Arts",
|
||||
"Mecha",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"School Life",
|
||||
"Sci-Fi",
|
||||
"Seinen",
|
||||
"Shoujo",
|
||||
"Shoujo Ai",
|
||||
"Shounen",
|
||||
"Shounen Ai",
|
||||
"Slice of Life",
|
||||
"Smut",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Tragedy",
|
||||
"Yaoi",
|
||||
"Yuri",
|
||||
)
|
||||
|
||||
/** Query to search by manga slug. */
|
||||
internal const val SLUG_QUERY = "slug:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangadventure
|
||||
|
||||
/** Generic results wrapper schema. */
|
||||
@kotlinx.serialization.Serializable
|
||||
internal class Results<T>(
|
||||
private val results: List<T>,
|
||||
) : Iterable<T> by results
|
||||
|
||||
/** Generic paginator schema. */
|
||||
@kotlinx.serialization.Serializable
|
||||
internal class Paginator<T>(
|
||||
val last: Boolean,
|
||||
private val results: List<T>,
|
||||
) : Iterable<T> by results
|
||||
|
||||
/** Page model schema. */
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Page(
|
||||
private val id: Int,
|
||||
val image: String,
|
||||
val number: Int,
|
||||
val url: String,
|
||||
) {
|
||||
override fun equals(other: Any?) =
|
||||
this === other || other is Page && id == other.id
|
||||
|
||||
override fun hashCode() = id
|
||||
}
|
||||
|
||||
/** Chapter model schema. */
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Chapter(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val number: Float,
|
||||
val volume: Int?,
|
||||
val published: String,
|
||||
val final: Boolean,
|
||||
val series: String,
|
||||
val groups: List<String>,
|
||||
val full_title: String,
|
||||
) {
|
||||
override fun equals(other: Any?) =
|
||||
this === other || other is Chapter && id == other.id
|
||||
|
||||
override fun hashCode() = id
|
||||
}
|
||||
|
||||
/** Series model schema. */
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class Series(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val cover: String,
|
||||
val description: String? = null,
|
||||
val status: String? = null,
|
||||
val licensed: Boolean? = null,
|
||||
val aliases: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val artists: List<String>? = null,
|
||||
val categories: List<String>? = null,
|
||||
) {
|
||||
override fun equals(other: Any?) =
|
||||
this === other || other is Series && slug == other.slug
|
||||
|
||||
override fun hashCode() = slug.hashCode()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangadventure
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts `{baseUrl}/reader/{slug}/`
|
||||
* intents and redirects them to the main Tachiyomi process.
|
||||
*/
|
||||
class MangAdventureActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val segments = intent?.data?.pathSegments
|
||||
if (segments != null && segments.size > 1) {
|
||||
val activity = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", MangAdventure.SLUG_QUERY + segments[1])
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
try {
|
||||
startActivity(activity)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e("MangAdventureActivity", ex.message, ex)
|
||||
}
|
||||
} else {
|
||||
Log.e("MangAdventureActivity", "Failed to parse URI from intent: $intent")
|
||||
}
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangadventure
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
internal interface UriFilter {
|
||||
val param: String
|
||||
|
||||
override fun toString(): String
|
||||
}
|
||||
|
||||
/** Filter representing the name of an author. */
|
||||
internal class Author : Filter.Text("Author"), UriFilter {
|
||||
override val param = "author"
|
||||
|
||||
override fun toString() = state
|
||||
}
|
||||
|
||||
/** Filter representing the name of an artist. */
|
||||
internal class Artist : Filter.Text("Artist"), UriFilter {
|
||||
override val param = "artist"
|
||||
|
||||
override fun toString() = state
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter representing the sort order.
|
||||
*
|
||||
* @param labels The site's sort order labels.
|
||||
*/
|
||||
internal class SortOrder(
|
||||
private val labels: Array<String>,
|
||||
) : Filter.Sort("Sort", labels, null), UriFilter {
|
||||
override val param = "sort"
|
||||
|
||||
override fun toString() = when (state?.ascending) {
|
||||
null -> ""
|
||||
true -> sorts[state!!.index]
|
||||
false -> "-" + sorts[state!!.index]
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The available sort order values. */
|
||||
private val sorts = arrayOf(
|
||||
"title",
|
||||
"views",
|
||||
"latest_upload",
|
||||
"chapter_count",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter representing the status of a manga.
|
||||
*
|
||||
* @param statuses The site's status names.
|
||||
*/
|
||||
internal class Status(
|
||||
statuses: Array<String>,
|
||||
) : Filter.Select<String>("Status", statuses), UriFilter {
|
||||
override val param = "status"
|
||||
|
||||
override fun toString() = values[state]
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter representing a manga category.
|
||||
*
|
||||
* @param name The display name of the category.
|
||||
*/
|
||||
internal class Category(name: String) : Filter.TriState(name)
|
||||
|
||||
/**
|
||||
* Filter representing the [categories][Category] of a manga.
|
||||
*
|
||||
* @param categories The site's manga categories.
|
||||
*/
|
||||
internal class CategoryList(
|
||||
categories: List<String>,
|
||||
) : Filter.Group<Category>("Categories", categories.map(::Category)), UriFilter {
|
||||
override val param = "categories"
|
||||
|
||||
override fun toString() = state.filterNot { it.isIgnored() }
|
||||
.joinToString(",") { if (it.isIncluded()) it.name else "-" + it.name }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangadventure
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
/** [MangAdventure] source generator. */
|
||||
class MangAdventureGenerator : ThemeSourceGenerator {
|
||||
override val themePkg = "mangadventure"
|
||||
|
||||
override val themeClass = "MangAdventure"
|
||||
|
||||
override val baseVersionCode = 12
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Arc-Relight", "https://arc-relight.com", "en", className = "ArcRelight"),
|
||||
SingleLang("Assorted Scans", "https://assortedscans.com", "en", overrideVersionCode = 2),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) = MangAdventureGenerator().createAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
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.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
abstract class MangaHub(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private var baseApiUrl = "https://api.mghubcdn.com"
|
||||
private var baseCdnUrl = "https://imgx.mghubcdn.com"
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.setRandomUserAgent(
|
||||
userAgentType = UserAgentType.DESKTOP,
|
||||
filterInclude = listOf("chrome"),
|
||||
)
|
||||
.addInterceptor(::apiAuthInterceptor)
|
||||
.rateLimit(1)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
|
||||
.add("Accept-Language", "en-US,en;q=0.5")
|
||||
.add("DNT", "1")
|
||||
.add("Referer", "$baseUrl/")
|
||||
.add("Sec-Fetch-Dest", "document")
|
||||
.add("Sec-Fetch-Mode", "navigate")
|
||||
.add("Sec-Fetch-Site", "same-origin")
|
||||
.add("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
open val json: Json by injectLazy()
|
||||
|
||||
private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val cookie = client.cookieJar
|
||||
.loadForRequest(baseUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
|
||||
|
||||
val request =
|
||||
if (originalRequest.url.toString() == "$baseApiUrl/graphql" && cookie != null) {
|
||||
originalRequest.newBuilder()
|
||||
.header("x-mhub-access", cookie.value)
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
}
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun refreshApiKey(chapter: SChapter) {
|
||||
val now = Calendar.getInstance().time.time
|
||||
|
||||
val slug = "$baseUrl${chapter.url}"
|
||||
.toHttpUrlOrNull()
|
||||
?.pathSegments
|
||||
?.get(1)
|
||||
|
||||
val url = if (slug != null) {
|
||||
"$baseUrl/manga/$slug".toHttpUrl()
|
||||
} else {
|
||||
baseUrl.toHttpUrl()
|
||||
}
|
||||
|
||||
// Clear key cookie
|
||||
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
|
||||
client.cookieJar.saveFromResponse(url, listOf(cookie))
|
||||
|
||||
// Set required cookie (for cache busting?)
|
||||
val recently = buildJsonObject {
|
||||
putJsonObject((now - (0..3600).random()).toString()) {
|
||||
put("mangaID", (1..42_000).random())
|
||||
put("number", (1..20).random())
|
||||
}
|
||||
}.toString()
|
||||
|
||||
client.cookieJar.saveFromResponse(
|
||||
url,
|
||||
listOf(
|
||||
Cookie.Builder()
|
||||
.domain(url.host)
|
||||
.name("recently")
|
||||
.value(URLEncoder.encode(recently, "utf-8"))
|
||||
.expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
|
||||
val request = GET("$url?reloadKey=1", headers)
|
||||
client.newCall(request).execute()
|
||||
}
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/popular/page/$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = ".col-sm-6:not(:has(a:contains(Yaoi)))"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(element.select("h4 a").attr("abs:href"))
|
||||
title = element.select("h4 a").text()
|
||||
thumbnail_url = element.select("img").attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "ul.pager li.next > a"
|
||||
|
||||
// latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/updates/page/$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
return popularMangaFromElement(element)
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search/page/$page".toHttpUrlOrNull()!!.newBuilder()
|
||||
url.addQueryParameter("q", query)
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is OrderBy -> {
|
||||
val order = filter.values[filter.state]
|
||||
url.addQueryParameter("order", order.key)
|
||||
}
|
||||
is GenreList -> {
|
||||
val genre = filter.values[filter.state]
|
||||
url.addQueryParameter("genre", genre.key)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
return popularMangaFromElement(element)
|
||||
}
|
||||
|
||||
// not sure if this still works, some duplicates i found is also using different thumbnail_url
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
/*
|
||||
* To remove duplicates we group by the thumbnail_url, which is
|
||||
* common between duplicates. The duplicates have a suffix in the
|
||||
* url "-by-{name}". Here we select the shortest url, to avoid
|
||||
* removing manga that has "by" in the title already.
|
||||
* Example:
|
||||
* /manga/tales-of-demons-and-gods (kept)
|
||||
* /manga/tales-of-demons-and-gods-by-mad-snail (removed)
|
||||
* /manga/leveling-up-by-only-eating (kept)
|
||||
*/
|
||||
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}.groupBy { it.thumbnail_url }.mapValues { (_, values) ->
|
||||
values.minByOrNull { it.url.length }!!
|
||||
}.values.toList()
|
||||
|
||||
val hasNextPage = searchMangaNextPageSelector().let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = document.select(".breadcrumb .active span").text()
|
||||
manga.author = document.select("div:has(h1) span:contains(Author) + span").first()?.text()
|
||||
manga.artist = document.select("div:has(h1) span:contains(Artist) + span").first()?.text()
|
||||
manga.genre = document.select(".row p a").joinToString { it.text() }
|
||||
manga.description = document.select(".tab-content p").first()?.text()
|
||||
manga.thumbnail_url = document.select("img.img-responsive").first()
|
||||
?.attr("src")
|
||||
|
||||
document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText ->
|
||||
when {
|
||||
statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING
|
||||
statusText.contains("completed", true) -> manga.status = SManga.COMPLETED
|
||||
else -> manga.status = SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// add alternative name to manga description
|
||||
document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName ->
|
||||
if (alternativeName.isNotBlank()) {
|
||||
manga.description = manga.description.orEmpty().let {
|
||||
if (it.isBlank()) {
|
||||
"Alternative Name: $alternativeName"
|
||||
} else {
|
||||
"$it\n\nAlternative Name: $alternativeName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
// chapters
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val head = document.head()
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it, head) }
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".tab-content ul li"
|
||||
|
||||
private fun chapterFromElement(element: Element, head: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
val potentialLinks = element.select("a[href*='$baseUrl/chapter/']:not([rel*=nofollow]):not([rel*=noreferrer])")
|
||||
var visibleLink = ""
|
||||
potentialLinks.forEach { a ->
|
||||
val className = a.className()
|
||||
val styles = head.select("style").html()
|
||||
if (!styles.contains(".$className { display:none; }")) {
|
||||
visibleLink = a.attr("href")
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
chapter.setUrlWithoutDomain(visibleLink)
|
||||
chapter.name = chapter.url.trimEnd('/').substringAfterLast('/').replace('-', ' ')
|
||||
chapter.date_upload = element.select("small.UovLc").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
throw UnsupportedOperationException("Not Used")
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val now = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
var parsedDate = 0L
|
||||
when {
|
||||
"just now" in date || "less than an hour" in date -> {
|
||||
parsedDate = now.timeInMillis
|
||||
}
|
||||
// parses: "1 hour ago" and "2 hours ago"
|
||||
"hour" in date -> {
|
||||
val hours = date.replaceAfter(" ", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.HOUR, -hours) }.timeInMillis
|
||||
}
|
||||
// parses: "Yesterday" and "2 days ago"
|
||||
"day" in date -> {
|
||||
val days = date.replace("days ago", "").trim().toIntOrNull() ?: 1
|
||||
parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -days) }.timeInMillis
|
||||
}
|
||||
// parses: "2 weeks ago"
|
||||
"weeks" in date -> {
|
||||
val weeks = date.replace("weeks ago", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -weeks) }.timeInMillis
|
||||
}
|
||||
// parses: "12-20-2019" and defaults everything that wasn't taken into account to 0
|
||||
else -> {
|
||||
try {
|
||||
parsedDate = dateFormat.parse(date)?.time ?: 0L
|
||||
} catch (e: ParseException) { /*nothing to do, parsedDate is initialized with 0L*/ }
|
||||
}
|
||||
}
|
||||
return parsedDate
|
||||
}
|
||||
|
||||
// pages
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val body = buildJsonObject {
|
||||
put("query", PAGES_QUERY)
|
||||
put(
|
||||
"variables",
|
||||
buildJsonObject {
|
||||
val mangaSource = when (name) {
|
||||
"MangaHub" -> "m01"
|
||||
"MangaReader.site" -> "mr01"
|
||||
"MangaPanda.onl" -> "mr02"
|
||||
else -> null
|
||||
}
|
||||
val chapterUrl = chapter.url.split("/")
|
||||
|
||||
put("mangaSource", mangaSource)
|
||||
put("slug", chapterUrl[2])
|
||||
put("number", chapterUrl[3].substringAfter("-").toFloat())
|
||||
},
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
.toRequestBody()
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Accept", "application/json")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Origin", baseUrl)
|
||||
.set("Sec-Fetch-Dest", "empty")
|
||||
.set("Sec-Fetch-Mode", "cors")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.removeAll("Upgrade-Insecure-Requests")
|
||||
.build()
|
||||
|
||||
return POST("$baseApiUrl/graphql", newHeaders, body)
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||
super.fetchPageList(chapter)
|
||||
.doOnError { refreshApiKey(chapter) }
|
||||
.retry(1)
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException("Not used")
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chapterObject = json.decodeFromString<ApiChapterPagesResponse>(response.body.string())
|
||||
|
||||
if (chapterObject.data?.chapter == null) {
|
||||
if (chapterObject.errors != null) {
|
||||
val errors = chapterObject.errors.joinToString("\n") { it.message }
|
||||
throw Exception(errors)
|
||||
}
|
||||
throw Exception("Unknown error while processing pages")
|
||||
}
|
||||
|
||||
val pages = json.decodeFromString<ApiChapterPages>(chapterObject.data.chapter.pages)
|
||||
|
||||
return pages.i.mapIndexed { i, page ->
|
||||
Page(i, "", "$baseCdnUrl/${pages.p}$page")
|
||||
}
|
||||
}
|
||||
|
||||
// Image
|
||||
override fun imageUrlRequest(page: Page): Request {
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
.set("Sec-Fetch-Dest", "image")
|
||||
.set("Sec-Fetch-Mode", "no-cors")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.removeAll("Upgrade-Insecure-Requests")
|
||||
.build()
|
||||
|
||||
return GET(page.url, newHeaders)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// filters
|
||||
private class Genre(title: String, val key: String) : Filter.TriState(title) {
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class Order(title: String, val key: String) : Filter.TriState(title) {
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class OrderBy(orders: Array<Order>) : Filter.Select<Order>("Order", orders, 0)
|
||||
private class GenreList(genres: Array<Genre>) : Filter.Select<Genre>("Genres", genres, 0)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
OrderBy(orderBy),
|
||||
GenreList(genres),
|
||||
)
|
||||
|
||||
private val orderBy = arrayOf(
|
||||
Order("Popular", "POPULAR"),
|
||||
Order("Updates", "LATEST"),
|
||||
Order("A-Z", "ALPHABET"),
|
||||
Order("New", "NEW"),
|
||||
Order("Completed", "COMPLETED"),
|
||||
)
|
||||
|
||||
private val genres = arrayOf(
|
||||
Genre("All Genres", "all"),
|
||||
Genre("[no chapters]", "no-chapters"),
|
||||
Genre("4-Koma", "4-koma"),
|
||||
Genre("Action", "action"),
|
||||
Genre("Adventure", "adventure"),
|
||||
Genre("Award Winning", "award-winning"),
|
||||
Genre("Comedy", "comedy"),
|
||||
Genre("Cooking", "cooking"),
|
||||
Genre("Crime", "crime"),
|
||||
Genre("Demons", "demons"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Drama", "drama"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Food", "food"),
|
||||
Genre("Game", "game"),
|
||||
Genre("Gender bender", "gender-bender"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Historical", "historical"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Isekai", "isekai"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Kids", "kids"),
|
||||
Genre("Magic", "magic"),
|
||||
Genre("Magical Girls", "magical-girls"),
|
||||
Genre("Manhua", "manhua"),
|
||||
Genre("Manhwa", "manhwa"),
|
||||
Genre("Martial arts", "martial-arts"),
|
||||
Genre("Mature", "mature"),
|
||||
Genre("Mecha", "mecha"),
|
||||
Genre("Medical", "medical"),
|
||||
Genre("Military", "military"),
|
||||
Genre("Music", "music"),
|
||||
Genre("Mystery", "mystery"),
|
||||
Genre("One shot", "one-shot"),
|
||||
Genre("Oneshot", "oneshot"),
|
||||
Genre("Parody", "parody"),
|
||||
Genre("Police", "police"),
|
||||
Genre("Psychological", "psychological"),
|
||||
Genre("Romance", "romance"),
|
||||
Genre("School life", "school-life"),
|
||||
Genre("Sci fi", "sci-fi"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Shotacon", "shotacon"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Shoujo ai", "shoujo-ai"),
|
||||
Genre("Shoujoai", "shoujoai"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Shounen ai", "shounen-ai"),
|
||||
Genre("Shounenai", "shounenai"),
|
||||
Genre("Slice of life", "slice-of-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Space", "space"),
|
||||
Genre("Sports", "sports"),
|
||||
Genre("Super Power", "super-power"),
|
||||
Genre("Superhero", "superhero"),
|
||||
Genre("Supernatural", "supernatural"),
|
||||
Genre("Thriller", "thriller"),
|
||||
Genre("Tragedy", "tragedy"),
|
||||
Genre("Vampire", "vampire"),
|
||||
Genre("Webtoon", "webtoon"),
|
||||
Genre("Webtoons", "webtoons"),
|
||||
Genre("Wuxia", "wuxia"),
|
||||
Genre("Yuri", "yuri"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaHubGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangahub"
|
||||
|
||||
override val themeClass = "MangaHub"
|
||||
|
||||
override val baseVersionCode: Int = 22
|
||||
|
||||
override val sources = listOf(
|
||||
// SingleLang("1Manga.co", "https://1manga.co", "en", isNsfw = true, className = "OneMangaCo"),
|
||||
// SingleLang("MangaFox.fun", "https://mangafox.fun", "en", isNsfw = true, className = "MangaFoxFun"),
|
||||
// SingleLang("MangaHere.onl", "https://mangahere.onl", "en", isNsfw = true, className = "MangaHereOnl"),
|
||||
SingleLang("MangaHub", "https://mangahub.io", "en", isNsfw = true, overrideVersionCode = 10, className = "MangaHubIo"),
|
||||
// SingleLang("Mangakakalot.fun", "https://mangakakalot.fun", "en", isNsfw = true, className = "MangakakalotFun"),
|
||||
// SingleLang("MangaNel", "https://manganel.me", "en", isNsfw = true),
|
||||
// SingleLang("MangaOnline.fun", "https://mangaonline.fun", "en", isNsfw = true, className = "MangaOnlineFun"),
|
||||
SingleLang("MangaPanda.onl", "https://mangapanda.onl", "en", className = "MangaPandaOnl"),
|
||||
SingleLang("MangaReader.site", "https://mangareader.site", "en", className = "MangaReaderSite"),
|
||||
// SingleLang("MangaToday", "https://mangatoday.fun", "en", isNsfw = true),
|
||||
// SingleLang("MangaTown (unoriginal)", "https://manga.town", "en", isNsfw = true, className = "MangaTownHub"),
|
||||
// SingleLang("MF Read Online", "https://mangafreereadonline.com", "en", isNsfw = true), // different pageListParse logic
|
||||
// SingleLang("OneManga.info", "https://onemanga.info", "en", isNsfw = true, className = "OneMangaInfo"), // Some chapters link to 1manga.co, hard to filter
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaHubGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
|
||||
|
||||
val PAGES_QUERY = buildQuery {
|
||||
"""
|
||||
query(%mangaSource: MangaSource, %slug: String!, %number: Float!) {
|
||||
chapter(x: %mangaSource, slug: %slug, number: %number) {
|
||||
pages
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorMessages(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPagesResponse(
|
||||
val data: ApiChapterData?,
|
||||
val errors: List<ApiErrorMessages>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterData(
|
||||
val chapter: ApiChapter?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapter(
|
||||
val pages: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPages(
|
||||
val p: String,
|
||||
val i: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangamainac
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.util.Calendar
|
||||
|
||||
// Based On TCBScans sources
|
||||
// MangaManiac is a network of sites built by Animemaniac.co.
|
||||
|
||||
abstract class MangaMainac(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
override val supportsLatest = false
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET(baseUrl)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "#page"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = element.select(".mangainfo_body > img").attr("src")
|
||||
manga.url = "" // element.select("#primary-menu .menu-item:first-child").attr("href")
|
||||
manga.title = element.select(".intro_content h2").text()
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
|
||||
// latest
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesSelector(): String = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesNextPageSelector(): String? = throw UnsupportedOperationException()
|
||||
|
||||
// search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException()
|
||||
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
|
||||
override fun searchMangaFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
override fun searchMangaNextPageSelector(): String? = throw UnsupportedOperationException()
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
val info = document.select(".intro_content").text()
|
||||
thumbnail_url = document.select(".mangainfo_body > img").attr("src")
|
||||
title = document.select(".intro_content h2").text()
|
||||
author = if ("Author" in info) substringextract(info, "Author(s):", "Released") else null
|
||||
artist = author
|
||||
genre = if ("Genre" in info) substringextract(info, "Genre(s):", "Status") else null
|
||||
status = parseStatus(document.select(".intro_content").text())
|
||||
description = if ("Description" in info) info.substringAfter("Description:").trim() else null
|
||||
}
|
||||
|
||||
private fun substringextract(text: String, start: String, end: String): String = text.substringAfter(start).substringBefore(end).trim()
|
||||
|
||||
private fun parseStatus(element: String): Int = when {
|
||||
element.lowercase().contains("ongoing (pub") -> SManga.ONGOING
|
||||
element.lowercase().contains("completed (pub") -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
// chapters
|
||||
override fun chapterListSelector() = "table.chap_tab tr"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val urlElement = element.select("a").first()!!
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = element.select("a").text()
|
||||
chapter.date_upload = element.select("#time i").last()?.text()?.let { parseChapterDate(it) }
|
||||
?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val dateWords: List<String> = date.split(" ")
|
||||
if (dateWords.size == 3) {
|
||||
val timeAgo = Integer.parseInt(dateWords[0])
|
||||
val dates: Calendar = Calendar.getInstance()
|
||||
when {
|
||||
dateWords[1].contains("minute") -> {
|
||||
dates.add(Calendar.MINUTE, -timeAgo)
|
||||
}
|
||||
dateWords[1].contains("hour") -> {
|
||||
dates.add(Calendar.HOUR_OF_DAY, -timeAgo)
|
||||
}
|
||||
dateWords[1].contains("day") -> {
|
||||
dates.add(Calendar.DAY_OF_YEAR, -timeAgo)
|
||||
}
|
||||
dateWords[1].contains("week") -> {
|
||||
dates.add(Calendar.WEEK_OF_YEAR, -timeAgo)
|
||||
}
|
||||
dateWords[1].contains("month") -> {
|
||||
dates.add(Calendar.MONTH, -timeAgo)
|
||||
}
|
||||
dateWords[1].contains("year") -> {
|
||||
dates.add(Calendar.YEAR, -timeAgo)
|
||||
}
|
||||
}
|
||||
return dates.timeInMillis
|
||||
}
|
||||
return 0L
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val chapterList = document.select(chapterListSelector()).map { chapterFromElement(it) }
|
||||
|
||||
return if (hasCountdown(chapterList[0])) {
|
||||
chapterList.subList(1, chapterList.size)
|
||||
} else {
|
||||
chapterList
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCountdown(chapter: SChapter): Boolean {
|
||||
val document = client.newCall(
|
||||
GET(
|
||||
baseUrl + chapter.url,
|
||||
headersBuilder().build(),
|
||||
),
|
||||
).execute().asJsoup()
|
||||
|
||||
return document
|
||||
.select("iframe[src^=https://free.timeanddate.com/countdown/]")
|
||||
.isNotEmpty()
|
||||
}
|
||||
|
||||
// pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val pages = mutableListOf<Page>()
|
||||
var i = 0
|
||||
document.select(".container .img_container center img").forEach { element ->
|
||||
val url = element.attr("src")
|
||||
i++
|
||||
if (url.isNotEmpty()) {
|
||||
pages.add(Page(i, "", url))
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangamainac
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaMainacGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangamainac"
|
||||
|
||||
override val themeClass = "MangaMainac"
|
||||
|
||||
override val baseVersionCode: Int = 1
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Read Boku No Hero Academia Manga Online", "https://w23.readheroacademia.com/", "en"),
|
||||
SingleLang("Read One Punch Man Manga Online", "https://w23.readonepunchman.net/", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Solo Leveling", "https://mysololeveling.com/", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Berserk Manga Online", "https://berserkmanga.net/", "en"),
|
||||
SingleLang("Read Domestic Girlfriend Manga", "https://domesticgirlfriend.net/", "en"),
|
||||
SingleLang("Read Black Clover Manga", "https://w6.blackclovermanga2.com/", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Shingeki no Kyojin Manga", "https://readshingekinokyojin.com/", "en"),
|
||||
SingleLang("Read Nanatsu no Taizai Manga", "https://w2.readnanatsutaizai.net/", "en", overrideVersionCode = 1),
|
||||
SingleLang("Read Rent a Girlfriend Manga", "https://kanojo-okarishimasu.com/", "en"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaMainacGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaraw
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
class ImageListParser(
|
||||
html: String,
|
||||
private val position: Int,
|
||||
private val keys: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
|
||||
private val pattern: String = """'BYFxAcGcC4.*?'""",
|
||||
) {
|
||||
|
||||
private val code: String
|
||||
init {
|
||||
code = getCode(html)
|
||||
}
|
||||
|
||||
fun getImageList(): List<String>? {
|
||||
val charMap = mutableMapOf<Int, String?>()
|
||||
for (j in 0..2) {
|
||||
charMap[j] = j.toString()
|
||||
}
|
||||
|
||||
val data = Data(getValue(0), position, 1)
|
||||
charMap[3] = when (getCharCode(data, position, 4)) {
|
||||
0 -> charCodeToString(getCharCode(data, position, 256))
|
||||
1 -> charCodeToString(getCharCode(data, position, 65536))
|
||||
2 -> return null
|
||||
else -> null
|
||||
}
|
||||
|
||||
val imageCharList = mutableListOf(charMap[3])
|
||||
|
||||
var counter = 4
|
||||
var charaIndexCounter = 4
|
||||
var n = 3.0
|
||||
var cash = charMap[3]
|
||||
while (data.index <= code.length) {
|
||||
val max = 2.0.pow(n).toInt()
|
||||
|
||||
val charIndex = when (val charCode = getCharCode(data, position, max)) {
|
||||
0 -> {
|
||||
charMap[charaIndexCounter] = charCodeToString(getCharCode(data, position, 256))
|
||||
counter--
|
||||
charaIndexCounter++
|
||||
}
|
||||
1 -> {
|
||||
charMap[charaIndexCounter] = charCodeToString(getCharCode(data, position, 65536))
|
||||
counter--
|
||||
charaIndexCounter++
|
||||
}
|
||||
2 -> {
|
||||
return imageCharList.joinToString("").split(",")
|
||||
}
|
||||
else -> {
|
||||
charCode
|
||||
}
|
||||
}
|
||||
|
||||
val char = if (!charMap[charIndex].isNullOrEmpty()) {
|
||||
charMap[charIndex]!!
|
||||
} else if (charIndex != charaIndexCounter) {
|
||||
return null
|
||||
} else {
|
||||
cash + cash?.charAt(0)
|
||||
}
|
||||
|
||||
if (counter == 0) {
|
||||
counter = 2.0.pow(n++).toInt()
|
||||
}
|
||||
|
||||
imageCharList.add(char)
|
||||
|
||||
charMap[charaIndexCounter++] = cash + char.charAt(0)
|
||||
counter--
|
||||
cash = char
|
||||
if (counter == 0) {
|
||||
counter = 2.0.pow(n++).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private data class Data(var value: Int, var position: Int, var index: Int)
|
||||
|
||||
private fun getCharCode(data: Data, position: Int, max: Int): Int {
|
||||
var charIndex = 0
|
||||
|
||||
var i = 1
|
||||
while (i != max) {
|
||||
val tmp = data.value and data.position
|
||||
data.position = data.position shr 1
|
||||
if (data.position == 0) {
|
||||
data.position = position
|
||||
data.value = getValue(data.index++)
|
||||
}
|
||||
|
||||
charIndex = charIndex or ((if (tmp > 0) 1 else 0) * i)
|
||||
i = i shl 1
|
||||
}
|
||||
|
||||
return charIndex
|
||||
}
|
||||
|
||||
fun getValue(index: Int): Int {
|
||||
return getValueByChar(code.charAt(index))
|
||||
}
|
||||
|
||||
private fun getCode(html: String): String {
|
||||
val regex = Regex(pattern)
|
||||
return regex.find(html)!!.value.replace("'", "").replace("\\", "").replace("u002b", "+")
|
||||
}
|
||||
|
||||
private fun getValueByChar(char: String): Int {
|
||||
return keys.indexOf(char)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun String.charAt(index: Int): String {
|
||||
return substring(index, index + 1)
|
||||
}
|
||||
|
||||
private fun charCodeToString(charCode: Int): String {
|
||||
return charCode.toChar().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaraw
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaRawGenerator : ThemeSourceGenerator {
|
||||
override val themeClass = "MangaRawTheme"
|
||||
|
||||
override val themePkg = "mangaraw"
|
||||
|
||||
override val baseVersionCode = 5
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("SyoSetu", "https://syosetu.top", "ja"),
|
||||
SingleLang("MangaRaw", "https://manga1001.in", "ja", pkgName = "manga9co", isNsfw = true, overrideVersionCode = 2),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaRawGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaraw
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
|
||||
abstract class MangaRawTheme(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "ja",
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
protected abstract fun String.sanitizeTitle(): String
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst(Evaluator.Tag("a"))!!.attr("href"))
|
||||
|
||||
// Title could be missing. Uses URL as a last effort, ex: okaeri-alice-raw
|
||||
title = element.selectFirst(Evaluator.Tag("h3"))?.text()?.sanitizeTitle()
|
||||
?: (baseUrl + url).toHttpUrl().pathSegments.first()
|
||||
|
||||
thumbnail_url = element.selectFirst(Evaluator.Tag("img"))?.absUrl("data-src")
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/$page", headers)
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
/** Other recommended manga must be removed. Make sure the last `<p>` is description. */
|
||||
protected abstract fun Document.getSanitizedDetails(): Element
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
val root = document.getSanitizedDetails()
|
||||
thumbnail_url = root.selectFirst(Evaluator.Tag("img"))?.run {
|
||||
absUrl("data-src").ifEmpty { absUrl("src") }
|
||||
}
|
||||
description = root.select(Evaluator.Tag("p")).lastOrNull { it.text().isNotEmpty() }
|
||||
?.run {
|
||||
select(Evaluator.Tag("br")).prepend("\\n")
|
||||
text().replace("\\n ", "\n")
|
||||
}
|
||||
genre = root.select(Evaluator.AttributeWithValueContaining("rel", "tag"))
|
||||
.flatMapTo(mutableSetOf()) { element ->
|
||||
val text = element.ownText()
|
||||
if (text.all { it.code < 128 }) return@flatMapTo listOf(text)
|
||||
text.split(' ', '.', '・', '。')
|
||||
}.joinToString()
|
||||
}
|
||||
|
||||
protected abstract fun String.sanitizeChapter(): String
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
name = element.text().sanitizeChapter()
|
||||
}
|
||||
|
||||
protected abstract fun pageSelector(): Evaluator
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val imgSelector = Evaluator.Tag("img")
|
||||
return document.select(pageSelector()).mapIndexed { index, element ->
|
||||
Page(index, imageUrl = element.selectFirst(imgSelector)!!.attr("data-src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangareader
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
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 okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
abstract class MangaReader : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
final override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
final override fun popularMangaParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
final override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
var entries = document.select(searchMangaSelector()).map(::searchMangaFromElement)
|
||||
if (preferences.getBoolean(SHOW_VOLUME_PREF, false)) {
|
||||
entries = entries.flatMapTo(ArrayList(entries.size * 2)) { manga ->
|
||||
val volume = SManga.create().apply {
|
||||
url = manga.url + VOLUME_URL_SUFFIX
|
||||
title = VOLUME_TITLE_PREFIX + manga.title
|
||||
thumbnail_url = manga.thumbnail_url
|
||||
}
|
||||
listOf(manga, volume)
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
|
||||
return MangasPage(entries, hasNextPage)
|
||||
}
|
||||
|
||||
final override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.removeSuffix(VOLUME_URL_SUFFIX)
|
||||
|
||||
abstract fun searchMangaSelector(): String
|
||||
|
||||
abstract fun searchMangaNextPageSelector(): String
|
||||
|
||||
abstract fun searchMangaFromElement(element: Element): SManga
|
||||
|
||||
abstract fun mangaDetailsParse(document: Document): SManga
|
||||
|
||||
final override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
val manga = mangaDetailsParse(document)
|
||||
if (response.request.url.fragment == VOLUME_URL_FRAGMENT) {
|
||||
manga.title = VOLUME_TITLE_PREFIX + manga.title
|
||||
}
|
||||
return manga
|
||||
}
|
||||
|
||||
abstract val chapterType: String
|
||||
abstract val volumeType: String
|
||||
|
||||
abstract fun chapterListRequest(mangaUrl: String, type: String): Request
|
||||
|
||||
abstract fun parseChapterElements(response: Response, isVolume: Boolean): List<Element>
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
open fun updateChapterList(manga: SManga, chapters: List<SChapter>) = Unit
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
|
||||
val path = manga.url
|
||||
val isVolume = path.endsWith(VOLUME_URL_SUFFIX)
|
||||
val type = if (isVolume) volumeType else chapterType
|
||||
val request = chapterListRequest(path.removeSuffix(VOLUME_URL_SUFFIX), type)
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
val abbrPrefix = if (isVolume) "Vol" else "Chap"
|
||||
val fullPrefix = if (isVolume) "Volume" else "Chapter"
|
||||
val linkSelector = Evaluator.Tag("a")
|
||||
parseChapterElements(response, isVolume).map { element ->
|
||||
SChapter.create().apply {
|
||||
val number = element.attr("data-number")
|
||||
chapter_number = number.toFloatOrNull() ?: -1f
|
||||
|
||||
val link = element.selectFirst(linkSelector)!!
|
||||
name = run {
|
||||
val name = link.text()
|
||||
val prefix = "$abbrPrefix $number: "
|
||||
if (!name.startsWith(prefix)) return@run name
|
||||
val realName = name.removePrefix(prefix)
|
||||
if (realName.contains(number)) realName else "$fullPrefix $number: $realName"
|
||||
}
|
||||
setUrlWithoutDomain(link.attr("href") + '#' + type + '/' + element.attr("data-id"))
|
||||
}
|
||||
}.also { if (!isVolume && it.isNotEmpty()) updateChapterList(manga, it) }
|
||||
}
|
||||
|
||||
final override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast('#')
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_VOLUME_PREF
|
||||
title = "Show volume entries in search result"
|
||||
setDefaultValue(false)
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHOW_VOLUME_PREF = "show_volume"
|
||||
|
||||
private const val VOLUME_URL_FRAGMENT = "vol"
|
||||
private const val VOLUME_URL_SUFFIX = "#" + VOLUME_URL_FRAGMENT
|
||||
private const val VOLUME_TITLE_PREFIX = "[VOL] "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangareader
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaReaderGenerator : ThemeSourceGenerator {
|
||||
override val themeClass = "MangaReader"
|
||||
override val themePkg = "mangareader"
|
||||
override val baseVersionCode = 1
|
||||
override val sources = listOf(
|
||||
MultiLang(
|
||||
name = "MangaReader",
|
||||
baseUrl = "https://mangareader.to",
|
||||
langs = listOf("en", "fr", "ja", "ko", "zh"),
|
||||
isNsfw = true,
|
||||
pkgName = "mangareaderto",
|
||||
overrideVersionCode = 3,
|
||||
),
|
||||
MultiLang(
|
||||
name = "MangaFire",
|
||||
baseUrl = "https://mangafire.to",
|
||||
langs = listOf("en", "es", "es-419", "fr", "ja", "pt", "pt-BR"),
|
||||
isNsfw = true,
|
||||
overrideVersionCode = 3,
|
||||
),
|
||||
SingleLang(
|
||||
name = "Manhuagold",
|
||||
baseUrl = "https://manhuagold.com",
|
||||
lang = "en",
|
||||
isNsfw = true,
|
||||
className = "Manhuagold",
|
||||
pkgName = "comickiba",
|
||||
overrideVersionCode = 33,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaReaderGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangathemesia
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
|
||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// Formerly WPMangaStream & WPMangaReader -> MangaThemesia
|
||||
abstract class MangaThemesia(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
val mangaUrlDirectory: String = "/manga",
|
||||
val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US),
|
||||
) : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
protected open val json: Json by injectLazy()
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.setRandomUserAgent(
|
||||
preferences.getPrefUAType(),
|
||||
preferences.getPrefCustomUA(),
|
||||
)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
open val projectPageString = "/project"
|
||||
|
||||
// Popular (Search with popular order and nothing else)
|
||||
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", FilterList(OrderByFilter("popular")))
|
||||
override fun popularMangaParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
// Latest (Search with update order and nothing else)
|
||||
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", FilterList(OrderByFilter("update")))
|
||||
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
// Search
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(URL_SEARCH_PREFIX).not()) return super.fetchSearchManga(page, query, filters)
|
||||
|
||||
val mangaPath = try {
|
||||
mangaPathFromUrl(query.substringAfter(URL_SEARCH_PREFIX))
|
||||
?: return Observable.just(MangasPage(emptyList(), false))
|
||||
} catch (e: Exception) {
|
||||
return Observable.error(e)
|
||||
}
|
||||
|
||||
return fetchMangaDetails(
|
||||
SManga.create()
|
||||
.apply { this.url = "$mangaUrlDirectory/$mangaPath/" },
|
||||
)
|
||||
.map {
|
||||
// Isn't set in returned manga
|
||||
it.url = "$mangaUrlDirectory/$mangaPath/"
|
||||
MangasPage(listOf(it), false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment(mangaUrlDirectory.substring(1))
|
||||
.addQueryParameter("title", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is AuthorFilter -> {
|
||||
url.addQueryParameter("author", filter.state)
|
||||
}
|
||||
is YearFilter -> {
|
||||
url.addQueryParameter("yearx", filter.state)
|
||||
}
|
||||
is StatusFilter -> {
|
||||
url.addQueryParameter("status", filter.selectedValue())
|
||||
}
|
||||
is TypeFilter -> {
|
||||
url.addQueryParameter("type", filter.selectedValue())
|
||||
}
|
||||
is OrderByFilter -> {
|
||||
url.addQueryParameter("order", filter.selectedValue())
|
||||
}
|
||||
is GenreListFilter -> {
|
||||
filter.state
|
||||
.filter { it.state != Filter.TriState.STATE_IGNORE }
|
||||
.forEach {
|
||||
val value = if (it.state == Filter.TriState.STATE_EXCLUDE) "-${it.value}" else it.value
|
||||
url.addQueryParameter("genre[]", value)
|
||||
}
|
||||
}
|
||||
// if site has project page, default value "hasProjectPage" = false
|
||||
is ProjectFilter -> {
|
||||
if (filter.selectedValue() == "project-filter-on") {
|
||||
url.setPathSegment(0, projectPageString.substring(1))
|
||||
}
|
||||
}
|
||||
else -> { /* Do Nothing */ }
|
||||
}
|
||||
}
|
||||
url.addPathSegment("")
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (genrelist == null) {
|
||||
genrelist = parseGenres(response.asJsoup(response.peekBody(Long.MAX_VALUE).string()))
|
||||
}
|
||||
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
thumbnail_url = element.select("img").imgAttr()
|
||||
title = element.select("a").attr("title")
|
||||
setUrlWithoutDomain(element.select("a").attr("href"))
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.pagination .next, div.hpage .r"
|
||||
|
||||
// Manga details
|
||||
open val seriesDetailsSelector = "div.bigcontent, div.animefull, div.main-info, div.postbody"
|
||||
open val seriesTitleSelector = "h1.entry-title"
|
||||
open val seriesArtistSelector = ".infotable tr:contains(artist) td:last-child, .tsinfo .imptdt:contains(artist) i, .fmed b:contains(artist)+span, span:contains(artist)"
|
||||
open val seriesAuthorSelector = ".infotable tr:contains(author) td:last-child, .tsinfo .imptdt:contains(author) i, .fmed b:contains(author)+span, span:contains(author)"
|
||||
open val seriesDescriptionSelector = ".desc, .entry-content[itemprop=description]"
|
||||
open val seriesAltNameSelector = ".alternative, .wd-full:contains(alt) span, .alter, .seriestualt"
|
||||
open val seriesGenreSelector = "div.gnr a, .mgen a, .seriestugenre a, span:contains(genre)"
|
||||
open val seriesTypeSelector = ".infotable tr:contains(type) td:last-child, .tsinfo .imptdt:contains(type) i, .tsinfo .imptdt:contains(type) a, .fmed b:contains(type)+span, span:contains(type) a, a[href*=type\\=]"
|
||||
open val seriesStatusSelector = ".infotable tr:contains(status) td:last-child, .tsinfo .imptdt:contains(status) i, .fmed b:contains(status)+span span:contains(status)"
|
||||
open val seriesThumbnailSelector = ".infomanga > div[itemprop=image] img, .thumb img"
|
||||
|
||||
open val altNamePrefix = "Alternative Name: "
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
document.selectFirst(seriesDetailsSelector)?.let { seriesDetails ->
|
||||
title = seriesDetails.selectFirst(seriesTitleSelector)?.text().orEmpty()
|
||||
artist = seriesDetails.selectFirst(seriesArtistSelector)?.ownText().removeEmptyPlaceholder()
|
||||
author = seriesDetails.selectFirst(seriesAuthorSelector)?.ownText().removeEmptyPlaceholder()
|
||||
description = seriesDetails.select(seriesDescriptionSelector).joinToString("\n") { it.text() }.trim()
|
||||
// Add alternative name to manga description
|
||||
val altName = seriesDetails.selectFirst(seriesAltNameSelector)?.ownText().takeIf { it.isNullOrBlank().not() }
|
||||
altName?.let {
|
||||
description = "$description\n\n$altNamePrefix$altName".trim()
|
||||
}
|
||||
val genres = seriesDetails.select(seriesGenreSelector).map { it.text() }.toMutableList()
|
||||
// Add series type (manga/manhwa/manhua/other) to genre
|
||||
seriesDetails.selectFirst(seriesTypeSelector)?.ownText().takeIf { it.isNullOrBlank().not() }?.let { genres.add(it) }
|
||||
genre = genres.map { genre ->
|
||||
genre.lowercase(Locale.forLanguageTag(lang)).replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) {
|
||||
char.titlecase(Locale.forLanguageTag(lang))
|
||||
} else {
|
||||
char.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
.joinToString { it.trim() }
|
||||
|
||||
status = seriesDetails.selectFirst(seriesStatusSelector)?.text().parseStatus()
|
||||
thumbnail_url = seriesDetails.select(seriesThumbnailSelector).imgAttr()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun String?.removeEmptyPlaceholder(): String? {
|
||||
return if (this.isNullOrBlank() || this == "-" || this == "N/A") null else this
|
||||
}
|
||||
|
||||
open fun String?.parseStatus(): Int = when {
|
||||
this == null -> SManga.UNKNOWN
|
||||
listOf("ongoing", "publishing").any { this.contains(it, ignoreCase = true) } -> SManga.ONGOING
|
||||
this.contains("hiatus", ignoreCase = true) -> SManga.ON_HIATUS
|
||||
this.contains("completed", ignoreCase = true) -> SManga.COMPLETED
|
||||
listOf("dropped", "cancelled").any { this.contains(it, ignoreCase = true) } -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
// Chapter list
|
||||
override fun chapterListSelector() = "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox):has(div.eph-num)"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val chapters = document.select(chapterListSelector()).map { chapterFromElement(it) }
|
||||
|
||||
// Add timestamp to latest chapter, taken from "Updated On".
|
||||
// So source which not provide chapter timestamp will have at least one
|
||||
if (chapters.isNotEmpty() && chapters.first().date_upload == 0L) {
|
||||
val date = document
|
||||
.select(".listinfo time[itemprop=dateModified], .fmed:contains(update) time, span:contains(update) time")
|
||||
.attr("datetime")
|
||||
if (date.isNotEmpty()) chapters.first().date_upload = parseUpdatedOnDate(date)
|
||||
}
|
||||
|
||||
countViews(document)
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
private fun parseUpdatedOnDate(date: String): Long {
|
||||
return SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH).parse(date)?.time ?: 0L
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
val urlElements = element.select("a")
|
||||
setUrlWithoutDomain(urlElements.attr("href"))
|
||||
name = element.select(".lch a, .chapternum").text().ifBlank { urlElements.first()!!.text() }
|
||||
date_upload = element.selectFirst(".chapterdate")?.text().parseChapterDate()
|
||||
}
|
||||
|
||||
protected open fun String?.parseChapterDate(): Long {
|
||||
if (this == null) return 0
|
||||
return try {
|
||||
dateFormat.parse(this)?.time ?: 0
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// Pages
|
||||
open val pageSelector = "div#readerarea img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val chapterUrl = document.location()
|
||||
val htmlPages = document.select(pageSelector)
|
||||
.filterNot { it.imgAttr().isEmpty() }
|
||||
.mapIndexed { i, img -> Page(i, chapterUrl, img.imgAttr()) }
|
||||
|
||||
countViews(document)
|
||||
|
||||
// Some sites also loads pages via javascript
|
||||
if (htmlPages.isNotEmpty()) { return htmlPages }
|
||||
|
||||
val docString = document.toString()
|
||||
val imageListJson = JSON_IMAGE_LIST_REGEX.find(docString)?.destructured?.toList()?.get(0).orEmpty()
|
||||
val imageList = try {
|
||||
json.parseToJsonElement(imageListJson).jsonArray
|
||||
} catch (_: IllegalArgumentException) {
|
||||
emptyList()
|
||||
}
|
||||
val scriptPages = imageList.mapIndexed { i, jsonEl ->
|
||||
Page(i, chapterUrl, jsonEl.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
return scriptPages
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Accept", "image/avif,image/webp,image/png,image/jpeg,*/*")
|
||||
.set("Referer", page.url)
|
||||
.build()
|
||||
|
||||
return GET(page.imageUrl!!, newHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set it to false if you want to disable the extension reporting the view count
|
||||
* back to the source website through admin-ajax.php.
|
||||
*/
|
||||
protected open val sendViewCount: Boolean = true
|
||||
|
||||
protected open fun countViewsRequest(document: Document): Request? {
|
||||
val wpMangaData = document.select("script:containsData(dynamic_view_ajax)").firstOrNull()
|
||||
?.data() ?: return null
|
||||
|
||||
val postId = CHAPTER_PAGE_ID_REGEX.find(wpMangaData)?.groupValues?.get(1)
|
||||
?: MANGA_PAGE_ID_REGEX.find(wpMangaData)?.groupValues?.get(1)
|
||||
?: return null
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("action", "dynamic_view_ajax")
|
||||
.add("post_id", postId)
|
||||
.build()
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Content-Length", formBody.contentLength().toString())
|
||||
.set("Content-Type", formBody.contentType().toString())
|
||||
.set("Referer", document.location())
|
||||
.build()
|
||||
|
||||
return POST("$baseUrl/wp-admin/admin-ajax.php", newHeaders, formBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the view count request to the sites endpoint.
|
||||
*
|
||||
* @param document The response document with the wp-manga data
|
||||
*/
|
||||
protected open fun countViews(document: Document) {
|
||||
if (!sendViewCount) {
|
||||
return
|
||||
}
|
||||
|
||||
val request = countViewsRequest(document) ?: return
|
||||
runCatching { client.newCall(request).execute().close() }
|
||||
}
|
||||
|
||||
// Filters
|
||||
protected class AuthorFilter : Filter.Text("Author")
|
||||
|
||||
protected class YearFilter : Filter.Text("Year")
|
||||
|
||||
open class SelectFilter(
|
||||
displayName: String,
|
||||
val vals: Array<Pair<String, String>>,
|
||||
defaultValue: String? = null,
|
||||
) : Filter.Select<String>(
|
||||
displayName,
|
||||
vals.map { it.first }.toTypedArray(),
|
||||
vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
|
||||
) {
|
||||
fun selectedValue() = vals[state].second
|
||||
}
|
||||
|
||||
protected class StatusFilter : SelectFilter(
|
||||
"Status",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Ongoing", "ongoing"),
|
||||
Pair("Completed", "completed"),
|
||||
Pair("Hiatus", "hiatus"),
|
||||
Pair("Dropped", "dropped"),
|
||||
),
|
||||
)
|
||||
|
||||
protected class TypeFilter : SelectFilter(
|
||||
"Type",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Manga", "Manga"),
|
||||
Pair("Manhwa", "Manhwa"),
|
||||
Pair("Manhua", "Manhua"),
|
||||
Pair("Comic", "Comic"),
|
||||
),
|
||||
)
|
||||
|
||||
protected class OrderByFilter(defaultOrder: String? = null) : SelectFilter(
|
||||
"Sort By",
|
||||
arrayOf(
|
||||
Pair("Default", ""),
|
||||
Pair("A-Z", "title"),
|
||||
Pair("Z-A", "titlereverse"),
|
||||
Pair("Latest Update", "update"),
|
||||
Pair("Latest Added", "latest"),
|
||||
Pair("Popular", "popular"),
|
||||
),
|
||||
defaultOrder,
|
||||
)
|
||||
|
||||
protected class ProjectFilter : SelectFilter(
|
||||
"Filter Project",
|
||||
arrayOf(
|
||||
Pair("Show all manga", ""),
|
||||
Pair("Show only project manga", "project-filter-on"),
|
||||
),
|
||||
)
|
||||
|
||||
protected class Genre(
|
||||
name: String,
|
||||
val value: String,
|
||||
state: Int = STATE_IGNORE,
|
||||
) : Filter.TriState(name, state)
|
||||
|
||||
protected class GenreListFilter(genres: List<Genre>) : Filter.Group<Genre>("Genre", genres)
|
||||
|
||||
private var genrelist: List<Genre>? = null
|
||||
protected open fun getGenreList(): List<Genre> {
|
||||
// Filters are fetched immediately once an extension loads
|
||||
// We're only able to get filters after a loading the manga directory,
|
||||
// and resetting the filters is the only thing that seems to reinflate the view
|
||||
return genrelist ?: listOf(Genre("Press reset to attempt to fetch genres", ""))
|
||||
}
|
||||
|
||||
open val hasProjectPage = false
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters = mutableListOf<Filter<*>>(
|
||||
Filter.Separator(),
|
||||
AuthorFilter(),
|
||||
YearFilter(),
|
||||
StatusFilter(),
|
||||
TypeFilter(),
|
||||
OrderByFilter(),
|
||||
Filter.Header("Genre exclusion is not available for all sources"),
|
||||
GenreListFilter(getGenreList()),
|
||||
)
|
||||
if (hasProjectPage) {
|
||||
filters.addAll(
|
||||
mutableListOf<Filter<*>>(
|
||||
Filter.Separator(),
|
||||
Filter.Header("NOTE: Can't be used with other filter!"),
|
||||
Filter.Header("$name Project List page"),
|
||||
ProjectFilter(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
/**
|
||||
* Given some string which represents an http urlString, returns path for a manga
|
||||
* which can be used to fetch its details at "$baseUrl$mangaUrlDirectory/$mangaPath"
|
||||
*
|
||||
* @param urlString: String
|
||||
*
|
||||
* @returns Path of a manga, or null if none could be found
|
||||
*/
|
||||
protected open fun mangaPathFromUrl(urlString: String): String? {
|
||||
val baseMangaUrl = "$baseUrl$mangaUrlDirectory".toHttpUrl()
|
||||
val url = urlString.toHttpUrlOrNull() ?: return null
|
||||
|
||||
val isMangaUrl = (baseMangaUrl.host == url.host && pathLengthIs(url, 2) && url.pathSegments[0] == baseMangaUrl.pathSegments[0])
|
||||
if (isMangaUrl) return url.pathSegments[1]
|
||||
|
||||
val potentiallyChapterUrl = pathLengthIs(url, 1)
|
||||
if (potentiallyChapterUrl) {
|
||||
val response = client.newCall(GET(urlString, headers)).execute()
|
||||
if (response.isSuccessful.not()) {
|
||||
response.close()
|
||||
throw IllegalStateException("HTTP error ${response.code}")
|
||||
} else if (response.isSuccessful) {
|
||||
val links = response.asJsoup().select("a[itemprop=item]")
|
||||
// near the top of page: home > manga > current chapter
|
||||
if (links.size == 3) {
|
||||
val newUrl = links[1].attr("href").toHttpUrlOrNull() ?: return null
|
||||
val isNewMangaUrl = (baseMangaUrl.host == newUrl.host && pathLengthIs(newUrl, 2) && newUrl.pathSegments[0] == baseMangaUrl.pathSegments[0])
|
||||
if (isNewMangaUrl) return newUrl.pathSegments[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun pathLengthIs(url: HttpUrl, n: Int, strict: Boolean = false): Boolean {
|
||||
return url.pathSegments.size == n && url.pathSegments[n - 1].isNotEmpty() ||
|
||||
(!strict && url.pathSegments.size == n + 1 && url.pathSegments[n].isEmpty())
|
||||
}
|
||||
|
||||
private fun parseGenres(document: Document): List<Genre>? {
|
||||
return document.selectFirst("ul.genrez")?.select("li")?.map { li ->
|
||||
Genre(
|
||||
li.selectFirst("label")!!.text(),
|
||||
li.selectFirst("input[type=checkbox]")!!.attr("value"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun Element.imgAttr(): String = when {
|
||||
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
|
||||
hasAttr("data-src") -> attr("abs:data-src")
|
||||
else -> attr("abs:src")
|
||||
}
|
||||
|
||||
protected open fun Elements.imgAttr(): String = this.first()!!.imgAttr()
|
||||
|
||||
// Unused
|
||||
override fun popularMangaSelector(): String = throw UnsupportedOperationException("Not used")
|
||||
override fun popularMangaFromElement(element: Element): SManga = throw UnsupportedOperationException("Not used")
|
||||
override fun popularMangaNextPageSelector(): String? = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun latestUpdatesSelector(): String = throw UnsupportedOperationException("Not used")
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException("Not used")
|
||||
override fun latestUpdatesNextPageSelector(): String? = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not Used")
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
addRandomUAPreferenceToScreen(screen)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val URL_SEARCH_PREFIX = "url:"
|
||||
|
||||
// More info: https://issuetracker.google.com/issues/36970498
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
private val MANGA_PAGE_ID_REGEX = "post_id\\s*:\\s*(\\d+)\\}".toRegex()
|
||||
private val CHAPTER_PAGE_ID_REGEX = "chapter_id\\s*=\\s*(\\d+);".toRegex()
|
||||
|
||||
val JSON_IMAGE_LIST_REGEX = "\"images\"\\s*:\\s*(\\[.*?])".toRegex()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangathemesia
|
||||
|
||||
import generator.ThemeSourceData.MultiLang
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
// Formerly WPMangaStream & WPMangaReader -> MangaThemesia
|
||||
class MangaThemesiaGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangathemesia"
|
||||
|
||||
override val themeClass = "MangaThemesia"
|
||||
|
||||
override val baseVersionCode: Int = 27
|
||||
|
||||
override val sources = listOf(
|
||||
MultiLang("Asura Scans", "https://asuratoon.com", listOf("en", "tr"), className = "AsuraScansFactory", pkgName = "asurascans", overrideVersionCode = 31),
|
||||
MultiLang("Miau Scan", "https://miaucomics.org", listOf("es", "pt-BR"), overrideVersionCode = 2),
|
||||
SingleLang("Ainz Scans ID", "https://ainzscans.site", "id"),
|
||||
SingleLang("AiYuManga", "https://aiyumanhua.com", "es", overrideVersionCode = 8),
|
||||
SingleLang("Alceascan", "https://alceascan.my.id", "id"),
|
||||
SingleLang("Animated Glitched Comics", "https://agscomics.com", "en"),
|
||||
SingleLang("Animated Glitched Scans", "https://anigliscans.xyz", "en", overrideVersionCode = 1),
|
||||
SingleLang("Arena Scans", "https://arenascans.net", "en", overrideVersionCode = 1),
|
||||
SingleLang("Arkham Scan", "https://arkhamscan.com", "pt-BR"),
|
||||
SingleLang("Arven Scans", "https://arvenscans.com", "en"),
|
||||
SingleLang("AscalonScans", "https://ascalonscans.com", "en"),
|
||||
SingleLang("Azure Scans", "https://azuremanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Banana-Scan", "https://banana-scan.com", "fr", className = "BananaScan", isNsfw = true),
|
||||
SingleLang("Beast Scans", "https://beastscans.net", "ar", overrideVersionCode = 1),
|
||||
SingleLang("Berserker Scan", "https://ragnascan.com", "es"),
|
||||
SingleLang("BirdManga", "https://birdmanga.com", "en"),
|
||||
SingleLang("Boosei", "https://boosei.net", "id", overrideVersionCode = 2),
|
||||
SingleLang("Cartel de Manhwas", "https://carteldemanhwas.com", "es", overrideVersionCode = 5),
|
||||
SingleLang("Cosmic Scans", "https://cosmic-scans.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("CosmicScans.id", "https://cosmicscans.id", "id", overrideVersionCode = 3, className = "CosmicScansID"),
|
||||
SingleLang("Cypher Scans", "https://cypherscans.xyz", "en"),
|
||||
SingleLang("Diskus Scan", "https://diskusscan.com", "pt-BR", overrideVersionCode = 8),
|
||||
SingleLang("Dojing.net", "https://dojing.net", "id", isNsfw = true, className = "DojingNet"),
|
||||
SingleLang("DuniaKomik.id", "https://duniakomik.org", "id", className = "DuniaKomikId", overrideVersionCode = 2),
|
||||
SingleLang("Elarc Toon", "https://elarctoon.com", "en", isNsfw = false, className = "ElarcPage", overrideVersionCode = 2),
|
||||
SingleLang("EnryuManga", "https://enryumanga.com", "en"),
|
||||
SingleLang("Epsilon Scan", "https://epsilonscan.fr", "fr", isNsfw = true),
|
||||
SingleLang("Evil production", "https://evil-manga.eu", "cs", isNsfw = true),
|
||||
SingleLang("Fairy Manga", "https://fairymanga.com", "en", className = "QueenScans", overrideVersionCode = 1),
|
||||
SingleLang("Flame Comics", "https://flamecomics.com", "en"),
|
||||
SingleLang("Franxx Mangás", "https://franxxmangas.net", "pt-BR", className = "FranxxMangas", isNsfw = true),
|
||||
SingleLang("Freak Scans", "https://freakscans.com", "en"),
|
||||
SingleLang("Glory Scans", "https://gloryscans.fr", "fr"),
|
||||
SingleLang("GoGoManga", "https://gogomanga.fun", "en", overrideVersionCode = 1),
|
||||
SingleLang("Gremory Mangas", "https://gremorymangas.com", "es"),
|
||||
SingleLang("Hanuman Scan", "https://hanumanscan.com", "en"),
|
||||
SingleLang("Heroxia", "https://heroxia.com", "id", isNsfw = true),
|
||||
SingleLang("Imagine Scan", "https://imaginescan.com.br", "pt-BR", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("InariManga", "https://inarimanga.com", "es", overrideVersionCode = 7),
|
||||
SingleLang("Infernal Void Scans", "https://void-scans.com", "en", overrideVersionCode = 5),
|
||||
SingleLang("Kai Scans", "https://kaiscans.com", "en", isNsfw = false),
|
||||
SingleLang("Kanzenin", "https://kanzenin.info", "id", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("KataKomik", "https://katakomik.online", "id"),
|
||||
SingleLang("King of Shojo", "https://kingofshojo.com", "ar", overrideVersionCode = 1),
|
||||
SingleLang("Kiryuu", "https://kiryuu.id", "id", overrideVersionCode = 6),
|
||||
SingleLang("Komik AV", "https://komikav.com", "id", overrideVersionCode = 1),
|
||||
SingleLang("Komik Cast", "https://komikcast.lol", "id", overrideVersionCode = 25),
|
||||
SingleLang("Komik Lab", "https://komiklab.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Komik Seru", "https://komikseru.me", "id", isNsfw = true),
|
||||
SingleLang("Komik Station", "https://komikstation.co", "id", overrideVersionCode = 4),
|
||||
SingleLang("KomikDewasa", "https://komikdewasa.org", "id", isNsfw = true),
|
||||
SingleLang("KomikIndo.co", "https://komikindo.co", "id", className = "KomikindoCo", overrideVersionCode = 3),
|
||||
SingleLang("KomikMama", "https://komikmama.co", "id", overrideVersionCode = 1),
|
||||
SingleLang("KomikManhwa", "https://komikmanhwa.me", "id", isNsfw = true),
|
||||
SingleLang("Komiksan", "https://komiksan.link", "id", overrideVersionCode = 2),
|
||||
SingleLang("Komiktap", "https://komiktap.me", "id", isNsfw = true),
|
||||
SingleLang("Komiku.com", "https://komiku.com", "id", className = "KomikuCom"),
|
||||
SingleLang("Kuma Scans (Kuma Translation)", "https://kumascans.com", "en", className = "KumaScans", overrideVersionCode = 1),
|
||||
SingleLang("KumaPoi", "https://kumapoi.info", "id", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("Legacy Scans", "https://legacy-scans.com", "fr", pkgName = "flamescansfr"),
|
||||
SingleLang("Lelmanga", "https://www.lelmanga.com", "fr"),
|
||||
SingleLang("LianScans", "https://www.lianscans.my.id", "id", isNsfw = true),
|
||||
SingleLang("Luminous Scans", "https://luminousscans.net", "en"),
|
||||
SingleLang("Lunar Scans", "https://lunarscan.org", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("LynxScans", "https://lynxscans.com", "en"),
|
||||
SingleLang("Lyra Scans", "https://lyrascans.com", "en"),
|
||||
SingleLang("Magus Manga", "https://magusmanga.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Manga Indo.me", "https://mangaindo.me", "id", className = "MangaIndoMe"),
|
||||
SingleLang("Manga Raw.org", "https://mangaraw.org", "ja", className = "MangaRawOrg", overrideVersionCode = 1),
|
||||
SingleLang("Mangacim", "https://www.mangacim.com", "tr", overrideVersionCode = 1),
|
||||
SingleLang("MangaKita", "https://mangakita.id", "id", overrideVersionCode = 2),
|
||||
SingleLang("Mangakyo", "https://mangakyo.org", "id", overrideVersionCode = 3),
|
||||
SingleLang("MangaShiina", "https://mangashiina.com", "es"),
|
||||
SingleLang("MangaShiro", "https://mangashiro.me", "id"),
|
||||
SingleLang("Mangasusu", "https://mangasusuku.xyz", "id", isNsfw = true, overrideVersionCode = 3),
|
||||
SingleLang("MangaSwat", "https://goldragon.me", "ar", overrideVersionCode = 15),
|
||||
SingleLang("MangaTale", "https://mangatale.co", "id", overrideVersionCode = 1),
|
||||
SingleLang("MangaWT", "https://mangawt.com", "tr", overrideVersionCode = 5),
|
||||
SingleLang("Mangayaro", "https://www.mangayaro.id", "id", overrideVersionCode = 1),
|
||||
SingleLang("Mangás Chan", "https://mangaschan.net", "pt-BR", className = "MangasChan", overrideVersionCode = 1),
|
||||
SingleLang("Mangás Online", "https://mangasonline.cc", "pt-BR", className = "MangasOnline"),
|
||||
SingleLang("Manhwa Freak", "https://manhwa-freak.com", "en", overrideVersionCode = 3),
|
||||
SingleLang("Manhwa Lover", "https://manhwalover.com", "en", isNsfw = true, overrideVersionCode = 1),
|
||||
SingleLang("ManhwaDesu", "https://manhwadesu.one", "id", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("ManhwaFreak", "https://manhwafreak.fr", "fr", className = "ManhwaFreakFR"),
|
||||
SingleLang("ManhwaIndo", "https://manhwaindo.id", "id", isNsfw = true, overrideVersionCode = 4),
|
||||
SingleLang("ManhwaLand.mom", "https://manhwaland.lat", "id", isNsfw = true, className = "ManhwaLandMom", overrideVersionCode = 5),
|
||||
SingleLang("ManhwaList", "https://manhwalist.com", "id", overrideVersionCode = 4),
|
||||
SingleLang("Manhwax", "https://manhwax.com", "en", isNsfw = true),
|
||||
SingleLang("Mareceh", "https://mareceh.com", "id", isNsfw = true, pkgName = "mangceh", overrideVersionCode = 10),
|
||||
SingleLang("MELOKOMIK", "https://melokomik.xyz", "id"),
|
||||
SingleLang("Mihentai", "https://mihentai.com", "all", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Mirai Scans", "https://miraiscans.com", "id"),
|
||||
SingleLang("MirrorDesu", "https://mirrordesu.me", "id", isNsfw = true),
|
||||
SingleLang("Natsu", "https://natsu.id", "id"),
|
||||
SingleLang("Nekomik", "https://nekomik.me", "id", overrideVersionCode = 2),
|
||||
SingleLang("NekoScans", "https://nekoscans.com", "es", isNsfw = true),
|
||||
SingleLang("Ngomik", "https://ngomik.net", "id", overrideVersionCode = 2),
|
||||
SingleLang("NIGHT SCANS", "https://nightscans.net", "en", isNsfw = true, className = "NightScans", overrideVersionCode = 3),
|
||||
SingleLang("Nocturnal Scans", "https://nocturnalscans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Nonbiri", "https://nonbiri.space", "id"),
|
||||
SingleLang("Noromax", "https://noromax.my.id", "id"),
|
||||
SingleLang("OPSCANS", "https://opscans.com", "all"),
|
||||
SingleLang("Origami Orpheans", "https://origami-orpheans.com", "pt-BR", overrideVersionCode = 10),
|
||||
SingleLang("Otsugami", "https://otsugami.id", "id"),
|
||||
SingleLang("Ozul Scans", "https://kingofmanga.com", "ar", overrideVersionCode = 2),
|
||||
SingleLang("Phantom Scans", "https://phantomscans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("PhenixScans", "https://phenixscans.fr", "fr", className = "PhenixScans", overrideVersionCode = 1),
|
||||
SingleLang("Pi Scans", "https://piscans.in", "id", overrideVersionCode = 1),
|
||||
SingleLang("PotatoManga", "https://potatomanga.xyz", "ar", overrideVersionCode = 1),
|
||||
SingleLang("Quantum Scans", "https://readers-point.space", "en"),
|
||||
SingleLang("Raiki Scan", "https://raikiscan.com", "es"),
|
||||
SingleLang("Raiscans", "https://www.raiscans.com", "en"),
|
||||
SingleLang("Raven Scans", "https://ravenscans.com", "en", overrideVersionCode = 1),
|
||||
SingleLang("Rawkuma", "https://rawkuma.com/", "ja"),
|
||||
SingleLang("ReadGojo", "https://readgojo.com", "en"),
|
||||
SingleLang("Readkomik", "https://readkomik.com", "en", className = "ReadKomik", overrideVersionCode = 1),
|
||||
SingleLang("Ryukonesia", "https://ryukonesia.net", "id"),
|
||||
SingleLang("Sekaikomik", "https://sekaikomik.bio", "id", isNsfw = true, overrideVersionCode = 11),
|
||||
SingleLang("Sekte Doujin", "https://sektedoujin.cc", "id", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("Senpai Ediciones", "http://senpaiediciones.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Shadow Mangas", "https://shadowmangas.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Shea Manga", "https://sheakomik.com", "id", overrideVersionCode = 4),
|
||||
SingleLang("Silence Scan", "https://silencescan.com.br", "pt-BR", isNsfw = true, overrideVersionCode = 5),
|
||||
SingleLang("Siren Komik", "https://sirenkomik.my.id", "id", className = "MangKomik", overrideVersionCode = 2),
|
||||
SingleLang("SkyMangas", "https://skymangas.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Snudae Scans", "https://snudaescans.com", "en", isNsfw = true, className = "BatotoScans", overrideVersionCode = 1),
|
||||
SingleLang("Soul Scans", "https://soulscans.my.id", "id", overrideVersionCode = 1),
|
||||
SingleLang("SSS Hentais", "https://hentais.sssscanlator.com", "pt-BR", isNsfw = true, className = "SssHentais", overrideVersionCode = 1),
|
||||
SingleLang("SSSScanlator", "https://sssscanlator.com", "pt-BR", overrideVersionCode = 1),
|
||||
SingleLang("Starlight Scan", "https://starligthscan.com", "pt-BR", isNsfw = true),
|
||||
SingleLang("Summer Fansub", "https://smmr.in", "pt-BR", isNsfw = true),
|
||||
SingleLang("SummerToon", "https://summertoon.com", "tr"),
|
||||
SingleLang("Surya Scans", "https://suryacomics.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Sushi-Scan", "https://sushiscan.net", "fr", className = "SushiScan", overrideVersionCode = 10),
|
||||
SingleLang("Sushiscan.fr", "https://anime-sama.me", "fr", className = "SushiScanFR", overrideVersionCode = 1),
|
||||
SingleLang("Tarot Scans", "https://www.tarotscans.com", "tr"),
|
||||
SingleLang("Tecno Scan", "https://tecnoscann.com", "es", isNsfw = true, overrideVersionCode = 6),
|
||||
SingleLang("TenkaiScan", "https://tenkaiscan.net", "es", isNsfw = true),
|
||||
SingleLang("Tenshi.id", "https://tenshi.id", "id", className = "TenshiId", pkgName = "masterkomik", overrideVersionCode = 4),
|
||||
SingleLang("The Apollo Team", "https://theapollo.team", "en"),
|
||||
SingleLang("Tres Daos Scan", "https://tresdaos.com", "es"),
|
||||
SingleLang("Tsundoku Traduções", "https://tsundoku.com.br", "pt-BR", className = "TsundokuTraducoes", overrideVersionCode = 9),
|
||||
SingleLang("TukangKomik", "https://tukangkomik.id", "id", overrideVersionCode = 1),
|
||||
SingleLang("TurkToon", "https://turktoon.com", "tr"),
|
||||
SingleLang("Uzay Manga", "https://uzaymanga.com", "tr", overrideVersionCode = 6),
|
||||
SingleLang("VF Scan", "https://www.vfscan.cc", "fr"),
|
||||
SingleLang("Walpurgi Scan", "https://www.walpurgiscan.it", "it", overrideVersionCode = 7, className = "WalpurgisScan", pkgName = "walpurgisscan"),
|
||||
SingleLang("West Manga", "https://westmanga.org", "id", overrideVersionCode = 2),
|
||||
SingleLang("World Romance Translation", "https://wrt.my.id", "id", overrideVersionCode = 11),
|
||||
SingleLang("xCaliBR Scans", "https://xcalibrscans.com", "en", overrideVersionCode = 5),
|
||||
SingleLang("YumeKomik", "https://yumekomik.com", "id", isNsfw = true, className = "YumeKomik", pkgName = "inazumanga", overrideVersionCode = 6),
|
||||
SingleLang("Zahard", "https://zahard.xyz", "en"),
|
||||
SingleLang("أريا مانجا", "https://www.areascans.net", "ar", className = "AreaManga"),
|
||||
SingleLang("فيكس مانجا", "https://vexmanga.com", "ar", className = "VexManga", overrideVersionCode = 3),
|
||||
SingleLang("สดใสเมะ", "https://www.xn--l3c0azab5a2gta.com", "th", isNsfw = true, className = "Sodsaime", overrideVersionCode = 1),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaThemesiaGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangathemesia
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MangaThemesiaUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
if (pathSegments != null && pathSegments.size >= 1) {
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${MangaThemesia.URL_SEARCH_PREFIX}${intent?.data?.toString()}")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("MangaThemesiaUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("MangaThemesiaUrlActivity", "Could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
MangaThemesia is WPMangaReader and WPMangaStream merged together as they both had similar code. Both theme was made by [Themesia Studios](https://themesia.com)
|
||||
@@ -0,0 +1,304 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaworld
|
||||
|
||||
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.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.lang.Exception
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class MangaWorld(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
companion object {
|
||||
protected val CHAPTER_NUMBER_REGEX by lazy { Regex("""(?i)capitolo\s([0-9]+)""") }
|
||||
|
||||
protected val DATE_FORMATTER by lazy { SimpleDateFormat("dd MMMM yyyy", Locale.ITALY) }
|
||||
protected val DATE_FORMATTER_2 by lazy { SimpleDateFormat("H", Locale.ITALY) }
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/archive?sort=most_read&page=$page", headers)
|
||||
}
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/?page=$page", headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.comics-grid .entry"
|
||||
override fun popularMangaSelector() = searchMangaSelector()
|
||||
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = element.select("a.thumb img").attr("src")
|
||||
element.select("a").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href").removeSuffix("/"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
return manga
|
||||
}
|
||||
override fun popularMangaFromElement(element: Element): SManga = searchMangaFromElement(element)
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = searchMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String? = null
|
||||
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
|
||||
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select(latestUpdatesSelector()).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}
|
||||
// nextPage is not possible because pagination is loaded after via Javascript
|
||||
// 16 is the default manga-per-page. If it is less than 16 then there's no next page
|
||||
val hasNextPage = mangas.size == 16
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/archive?page=$page".toHttpUrlOrNull()!!.newBuilder()
|
||||
url.addQueryParameter("keyword", query)
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreList ->
|
||||
filter.state.filter { it.state }.forEach {
|
||||
url.addQueryParameter("genre", it.id)
|
||||
}
|
||||
is StatusList ->
|
||||
filter.state.filter { it.state }.forEach {
|
||||
url.addQueryParameter("status", it.id)
|
||||
}
|
||||
is MTypeList ->
|
||||
filter.state.filter { it.state }.forEach {
|
||||
url.addQueryParameter("type", it.id)
|
||||
}
|
||||
is SortBy -> url.addQueryParameter("sort", filter.toUriPart())
|
||||
is TextField -> url.addQueryParameter(filter.key, filter.state)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select("div.comic-info")
|
||||
if (infoElement.isEmpty()) {
|
||||
throw Exception("Page not found")
|
||||
}
|
||||
|
||||
val manga = SManga.create()
|
||||
manga.author = infoElement.select("a[href*=/archive?author=]").first()?.text()
|
||||
manga.artist = infoElement.select("a[href*=/archive?artist=]").text()
|
||||
manga.thumbnail_url = infoElement.select(".thumb > img").attr("src")
|
||||
|
||||
var description = document.select("div#noidungm").text()
|
||||
val otherTitle = document.select("div.meta-data > div").first()?.text()
|
||||
if (!otherTitle.isNullOrBlank() && otherTitle.contains("Titoli alternativi")) {
|
||||
description += "\n\n$otherTitle"
|
||||
}
|
||||
manga.description = description.trim()
|
||||
|
||||
manga.genre = infoElement.select("div.meta-data a.badge").joinToString(", ") {
|
||||
it.text()
|
||||
}
|
||||
|
||||
val status = infoElement.select("a[href*=/archive?status=]").first()?.text()
|
||||
manga.status = parseStatus(status)
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
protected fun parseStatus(element: String?): Int {
|
||||
if (element.isNullOrEmpty()) {
|
||||
return SManga.UNKNOWN
|
||||
}
|
||||
return when (element.lowercase()) {
|
||||
"in corso" -> SManga.ONGOING
|
||||
"finito" -> SManga.COMPLETED
|
||||
"in pausa" -> SManga.ON_HIATUS
|
||||
"cancellato" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".chapters-wrapper .chapter"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
|
||||
val url = element.select("a.chap").first()?.attr("href")
|
||||
?: throw throw Exception("Url not found")
|
||||
chapter.setUrlWithoutDomain(fixChapterUrl(url))
|
||||
|
||||
val name = element.select("span.d-inline-block").first()?.text() ?: ""
|
||||
chapter.name = name
|
||||
|
||||
val date = parseChapterDate(element.select(".chap-date").last()?.text())
|
||||
chapter.date_upload = date
|
||||
|
||||
val number = parseChapterNumber(name)
|
||||
if (number != null) {
|
||||
chapter.chapter_number = number
|
||||
}
|
||||
return chapter
|
||||
}
|
||||
|
||||
protected fun fixChapterUrl(url: String?): String {
|
||||
if (url.isNullOrEmpty()) {
|
||||
return ""
|
||||
}
|
||||
val params = url.split("?").let { if (it.size > 1) it[1] else "" }
|
||||
return when {
|
||||
params.contains("style=list") -> url
|
||||
params.contains("style=pages") ->
|
||||
url.replace("style=pages", "style=list")
|
||||
params.isEmpty() -> "$url?style=list"
|
||||
else -> "$url&style=list"
|
||||
}
|
||||
}
|
||||
|
||||
protected fun parseChapterDate(string: String?): Long {
|
||||
if (string == null) {
|
||||
return 0L
|
||||
}
|
||||
return runCatching { DATE_FORMATTER.parse(string)?.time }.getOrNull()
|
||||
?: runCatching { DATE_FORMATTER_2.parse(string)?.time }.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
protected fun parseChapterNumber(string: String): Float? {
|
||||
return CHAPTER_NUMBER_REGEX.find(string)?.let {
|
||||
it.groups[1]?.value?.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("div#page img.page-image").mapIndexed { index, it ->
|
||||
val url = it.attr("src")
|
||||
Page(index, imageUrl = url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imgHeader = Headers.Builder().apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30")
|
||||
add("Referer", baseUrl)
|
||||
}.build()
|
||||
return GET(page.imageUrl!!, imgHeader)
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
TextField("Anno di uscita", "year"),
|
||||
SortBy(),
|
||||
StatusList(getStatusList()),
|
||||
GenreList(getGenreList()),
|
||||
MTypeList(getTypesList()),
|
||||
)
|
||||
|
||||
private class SortBy : UriPartFilter(
|
||||
"Ordina per",
|
||||
arrayOf(
|
||||
Pair("Rilevanza", ""),
|
||||
Pair("Più letti", "most_read"),
|
||||
Pair("Meno letti", "less_read"),
|
||||
Pair("Più recenti", "newest"),
|
||||
Pair("Meno recenti", "oldest"),
|
||||
Pair("A-Z", "a-z"),
|
||||
Pair("Z-A", "z-a"),
|
||||
),
|
||||
)
|
||||
|
||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
||||
|
||||
class Genre(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Generi", genres)
|
||||
|
||||
class MType(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||
private class MTypeList(types: List<MType>) : Filter.Group<MType>("Tipologia", types)
|
||||
|
||||
class Status(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||
private class StatusList(statuses: List<Status>) : Filter.Group<Status>("Stato", statuses)
|
||||
|
||||
protected fun getGenreList() = listOf(
|
||||
Genre("Adulti", "adulti"),
|
||||
Genre("Arti Marziali", "arti-marziali"),
|
||||
Genre("Avventura", "avventura"),
|
||||
Genre("Azione", "azione"),
|
||||
Genre("Commedia", "commedia"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Drammatico", "drammatico"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Gender Bender", "gender-bender"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Hentai", "hentai"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Lolicon", "lolicon"),
|
||||
Genre("Maturo", "maturo"),
|
||||
Genre("Mecha", "mecha"),
|
||||
Genre("Mistero", "mistero"),
|
||||
Genre("Psicologico", "psicologico"),
|
||||
Genre("Romantico", "romantico"),
|
||||
Genre("Sci-fi", "sci-fi"),
|
||||
Genre("Scolastico", "scolastico"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Shotacon", "shotacon"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Shoujo Ai", "shoujo-ai"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Shounen Ai", "shounen-ai"),
|
||||
Genre("Slice of Life", "slice-of-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Soprannaturale", "soprannaturale"),
|
||||
Genre("Sport", "sport"),
|
||||
Genre("Storico", "storico"),
|
||||
Genre("Tragico", "tragico"),
|
||||
Genre("Yaoi", "yaoi"),
|
||||
Genre("Yuri", "yuri"),
|
||||
)
|
||||
protected fun getTypesList() = listOf(
|
||||
MType("Manga", "manga"),
|
||||
MType("Manhua", "manhua"),
|
||||
MType("Manhwa", "manhwa"),
|
||||
MType("Oneshot", "oneshot"),
|
||||
MType("Thai", "thai"),
|
||||
MType("Vietnamita", "vietnamese"),
|
||||
)
|
||||
protected fun getStatusList() = listOf(
|
||||
Status("In corso", "ongoing"),
|
||||
Status("Finito", "completed"),
|
||||
Status("Droppato", "dropped"),
|
||||
Status("In pausa", "paused"),
|
||||
Status("Cancellato", "canceled"),
|
||||
)
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaworld
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MangaWorldGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mangaworld"
|
||||
override val themeClass = "MangaWorld"
|
||||
override val baseVersionCode: Int = 2
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("Mangaworld", "https://www.mangaworld.so", "it", pkgName = "mangaworld", overrideVersionCode = 5),
|
||||
SingleLang("MangaworldAdult", "https://www.mangaworldadult.com", "it", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MangaWorldGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object DecryptInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val host = request.url.host
|
||||
val type = when {
|
||||
host.endsWith("bcebos.com") -> 1
|
||||
host.endsWith("mhrsrc.com") -> 2
|
||||
else -> return response
|
||||
}
|
||||
val data = decrypt(response.body.bytes(), type)
|
||||
val body = data.toResponseBody("image/jpeg".toMediaType())
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun decrypt(input: ByteArray, type: Int): ByteArray {
|
||||
val cipher = cipher
|
||||
val decodedInput: ByteArray
|
||||
when (type) {
|
||||
1 -> {
|
||||
decodedInput = input
|
||||
cipher.init(Cipher.DECRYPT_MODE, key1, iv)
|
||||
}
|
||||
2 -> {
|
||||
decodedInput = Base64.decode(input, Base64.DEFAULT)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key2, iv2)
|
||||
}
|
||||
else -> return input
|
||||
}
|
||||
return cipher.doFinal(decodedInput)
|
||||
}
|
||||
|
||||
private val cipher by lazy(LazyThreadSafetyMode.NONE) { Cipher.getInstance("DESede/CBC/PKCS5Padding") }
|
||||
private val key1 by lazy(LazyThreadSafetyMode.NONE) { SecretKeySpec("OW84U8Eerdb99rtsTXWSILDO".toByteArray(), "DESede") }
|
||||
private val key2 by lazy(LazyThreadSafetyMode.NONE) { SecretKeySpec("ys6n2GvmgEyB3rELDX1gaTBf".toByteArray(), "DESede") }
|
||||
private val iv by lazy(LazyThreadSafetyMode.NONE) { IvParameterSpec("SK8bncVu".toByteArray()) }
|
||||
private val iv2 by lazy(LazyThreadSafetyMode.NONE) { IvParameterSpec("2pnB3NI2".toByteArray()) }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* 漫城CMS http://mccms.cn/
|
||||
*/
|
||||
open class MCCMS(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "zh",
|
||||
hasCategoryPage: Boolean = false,
|
||||
) : HttpSource() {
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override val client by lazy {
|
||||
network.client.newBuilder()
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 2)
|
||||
.addInterceptor(DecryptInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
val pcHeaders by lazy { super.headersBuilder().build() }
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.add("User-Agent", System.getProperty("http.agent")!!)
|
||||
.add("Referer", baseUrl)
|
||||
|
||||
protected open fun SManga.cleanup(): SManga = this
|
||||
protected open fun MangaDto.prepare(): MangaDto = this
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/api/data/comic?page=$page&size=$PAGE_SIZE&order=hits", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val list: List<MangaDto> = response.parseAs()
|
||||
return MangasPage(list.map { it.prepare().toSManga().cleanup() }, list.size >= PAGE_SIZE)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/api/data/comic?page=$page&size=$PAGE_SIZE&order=addtime", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val queries = buildList {
|
||||
add("page=$page")
|
||||
add("size=$PAGE_SIZE")
|
||||
val isTextSearch = query.isNotBlank()
|
||||
if (isTextSearch) add("key=$query")
|
||||
for (filter in filters) if (filter is MCCMSFilter) {
|
||||
if (isTextSearch && filter.isTypeQuery) continue
|
||||
val part = filter.query
|
||||
if (part.isNotEmpty()) add(part)
|
||||
}
|
||||
}
|
||||
val url = buildString {
|
||||
append(baseUrl).append("/api/data/comic?")
|
||||
queries.joinTo(this, separator = "&")
|
||||
}
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
// preserve mangaDetailsRequest for WebView
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
val url = "$baseUrl/api/data/comic".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("key", manga.title)
|
||||
.toString()
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess().map { response ->
|
||||
val list = response.parseAs<List<MangaDto>>().map { it.prepare() }
|
||||
list.find { it.url == manga.url }!!.toSManga().cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
|
||||
val id = getMangaId(manga.url)
|
||||
val dataResponse = client.newCall(GET("$baseUrl/api/data/chapter?mid=$id", headers)).execute()
|
||||
val dataList: List<ChapterDataDto> = dataResponse.parseAs() // unordered
|
||||
val dateMap = HashMap<Int, Long>(dataList.size * 2)
|
||||
dataList.forEach { dateMap[it.id.toInt()] = it.date }
|
||||
val response = client.newCall(GET("$baseUrl/api/comic/chapter?mid=$id", headers)).execute()
|
||||
val list: List<ChapterDto> = response.parseAs()
|
||||
val result = list.map { it.toSChapter(date = dateMap[it.id.toInt()] ?: 0) }.asReversed()
|
||||
result
|
||||
}
|
||||
|
||||
protected open fun getMangaId(url: String) = url.substringAfterLast('/')
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
GET(baseUrl + chapter.url, pcHeaders)
|
||||
|
||||
protected open val lazyLoadImageAttr = "data-original"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
return document.select("img[$lazyLoadImageAttr]").mapIndexed { i, element ->
|
||||
Page(i, imageUrl = element.attr(lazyLoadImageAttr))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun imageRequest(page: Page) = GET(page.imageUrl!!, pcHeaders)
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T = use {
|
||||
json.decodeFromStream<ResultDto<T>>(it.body.byteStream()).data
|
||||
}
|
||||
|
||||
val genreData = GenreData(hasCategoryPage)
|
||||
|
||||
fun fetchGenres() {
|
||||
if (genreData.status != GenreData.NOT_FETCHED) return
|
||||
genreData.status = GenreData.FETCHING
|
||||
thread {
|
||||
try {
|
||||
val response = client.newCall(GET("$baseUrl/category/", pcHeaders)).execute()
|
||||
parseGenres(response.asJsoup(), genreData)
|
||||
} catch (e: Exception) {
|
||||
genreData.status = GenreData.NOT_FETCHED
|
||||
Log.e("MCCMS/$name", "failed to fetch genres", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
fetchGenres()
|
||||
return getFilters(genreData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal const val PAGE_SIZE = 30
|
||||
|
||||
@Serializable
|
||||
data class MangaDto(
|
||||
val id: String,
|
||||
private val name: String,
|
||||
private val pic: String,
|
||||
private val serialize: String,
|
||||
private val author: String,
|
||||
private val content: String,
|
||||
private val addtime: String,
|
||||
val url: String,
|
||||
private val tags: List<String>,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = this@MangaDto.url
|
||||
title = name
|
||||
author = this@MangaDto.author
|
||||
description = content
|
||||
genre = tags.joinToString()
|
||||
val date = dateFormat.parse(addtime)?.time ?: 0
|
||||
val isUpdating = System.currentTimeMillis() - date <= 30L * 24 * 3600 * 1000 // a month
|
||||
status = when {
|
||||
'连' in serialize || isUpdating -> SManga.ONGOING
|
||||
'完' in serialize -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
thumbnail_url = pic
|
||||
initialized = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val dateFormat by lazy { getDateFormat() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(val id: String, private val name: String, private val link: String) {
|
||||
fun toSChapter(date: Long) = SChapter.create().apply {
|
||||
url = link
|
||||
name = this@ChapterDto.name
|
||||
date_upload = date
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterDataDto(val id: String, private val addtime: String) {
|
||||
val date get() = dateFormat.parse(addtime)?.time ?: 0
|
||||
|
||||
companion object {
|
||||
private val dateFormat by lazy { getDateFormat() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ResultDto<T>(val data: T)
|
||||
|
||||
fun getDateFormat() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
|
||||
@@ -0,0 +1,103 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
open class MCCMSFilter(
|
||||
name: String,
|
||||
values: Array<String>,
|
||||
private val queries: Array<String>,
|
||||
val isTypeQuery: Boolean = false,
|
||||
) : Filter.Select<String>(name, values) {
|
||||
val query get() = queries[state]
|
||||
}
|
||||
|
||||
class SortFilter : MCCMSFilter("排序", SORT_NAMES, SORT_QUERIES)
|
||||
class WebSortFilter : MCCMSFilter("排序", SORT_NAMES, SORT_QUERIES_WEB)
|
||||
|
||||
private val SORT_NAMES = arrayOf("热门人气", "更新时间", "评分")
|
||||
private val SORT_QUERIES = arrayOf("order=hits", "order=addtime", "order=score")
|
||||
private val SORT_QUERIES_WEB = arrayOf("order/hits", "order/addtime", "order/score")
|
||||
|
||||
class StatusFilter : MCCMSFilter("进度", STATUS_NAMES, STATUS_QUERIES)
|
||||
class WebStatusFilter : MCCMSFilter("进度", STATUS_NAMES, STATUS_QUERIES_WEB)
|
||||
|
||||
private val STATUS_NAMES = arrayOf("全部", "连载", "完结")
|
||||
private val STATUS_QUERIES = arrayOf("", "serialize=连载", "serialize=完结")
|
||||
private val STATUS_QUERIES_WEB = arrayOf("", "finish/1", "finish/2")
|
||||
|
||||
class GenreFilter(private val values: Array<String>, private val queries: Array<String>) {
|
||||
|
||||
private val apiQueries get() = queries.run {
|
||||
Array(size) { i -> "type[tags]=" + this[i] }
|
||||
}
|
||||
|
||||
private val webQueries get() = queries.run {
|
||||
Array(size) { i -> "tags/" + this[i] }
|
||||
}
|
||||
|
||||
val filter get() = MCCMSFilter("标签(搜索文本时无效)", values, apiQueries, isTypeQuery = true)
|
||||
val webFilter get() = MCCMSFilter("标签", values, webQueries, isTypeQuery = true)
|
||||
}
|
||||
|
||||
class GenreData(hasCategoryPage: Boolean) {
|
||||
var status = if (hasCategoryPage) NOT_FETCHED else NO_DATA
|
||||
lateinit var genreFilter: GenreFilter
|
||||
|
||||
companion object {
|
||||
const val NOT_FETCHED = 0
|
||||
const val FETCHING = 1
|
||||
const val FETCHED = 2
|
||||
const val NO_DATA = 3
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseGenres(document: Document, genreData: GenreData) {
|
||||
val genres = document.select("a[href^=/category/tags/]")
|
||||
if (genres.isEmpty()) {
|
||||
genreData.status = GenreData.NO_DATA
|
||||
return
|
||||
}
|
||||
val result = buildList(genres.size + 1) {
|
||||
add(Pair("全部", ""))
|
||||
genres.mapTo(this) {
|
||||
val tagId = it.attr("href").substringAfterLast('/')
|
||||
Pair(it.text(), tagId)
|
||||
}
|
||||
}
|
||||
genreData.genreFilter = GenreFilter(
|
||||
values = result.map { it.first }.toTypedArray(),
|
||||
queries = result.map { it.second }.toTypedArray(),
|
||||
)
|
||||
genreData.status = GenreData.FETCHED
|
||||
}
|
||||
|
||||
internal fun getFilters(genreData: GenreData): FilterList {
|
||||
val list = buildList(4) {
|
||||
add(StatusFilter())
|
||||
add(SortFilter())
|
||||
if (genreData.status == GenreData.NO_DATA) return@buildList
|
||||
add(Filter.Separator())
|
||||
if (genreData.status == GenreData.FETCHED) {
|
||||
add(genreData.genreFilter.filter)
|
||||
} else {
|
||||
add(Filter.Header("点击“重置”尝试刷新标签分类"))
|
||||
}
|
||||
}
|
||||
return FilterList(list)
|
||||
}
|
||||
|
||||
internal fun getWebFilters(genreData: GenreData): FilterList {
|
||||
val list = buildList(4) {
|
||||
add(Filter.Header("分类筛选(搜索时无效)"))
|
||||
add(WebStatusFilter())
|
||||
add(WebSortFilter())
|
||||
when (genreData.status) {
|
||||
GenreData.NO_DATA -> return@buildList
|
||||
GenreData.FETCHED -> add(genreData.genreFilter.webFilter)
|
||||
else -> add(Filter.Header("点击“重置”尝试刷新标签分类"))
|
||||
}
|
||||
}
|
||||
return FilterList(list)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MCCMSGenerator : ThemeSourceGenerator {
|
||||
override val themeClass = "MCCMS"
|
||||
override val themePkg = "mccms"
|
||||
override val baseVersionCode = 6
|
||||
override val sources = listOf(
|
||||
SingleLang(
|
||||
name = "Kuaikuai Manhua 3",
|
||||
baseUrl = "https://mobile3.manhuaorg.com",
|
||||
lang = "zh",
|
||||
className = "Kuaikuai3",
|
||||
sourceName = "快快漫画3",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
SingleLang(
|
||||
name = "Manhuawu",
|
||||
baseUrl = "https://www.mhua5.com",
|
||||
lang = "zh",
|
||||
className = "Manhuawu",
|
||||
sourceName = "漫画屋",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
// The following sources are from https://www.yy123.cyou/ and are configured to use MCCMSNsfw
|
||||
SingleLang( // 103=校园梦精记, same as: www.hmanwang.com, www.quanman8.com, www.lmmh.cc, www.xinmanba.com
|
||||
name = "Dida Manhua",
|
||||
baseUrl = "https://www.didamanhua.com",
|
||||
lang = "zh",
|
||||
isNsfw = true,
|
||||
className = "DidaManhua",
|
||||
sourceName = "嘀嗒漫画",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
SingleLang( // 103=脱身之法, same as: www.quanmanba.com, www.999mh.net
|
||||
name = "Dimanba",
|
||||
baseUrl = "https://www.dimanba.com",
|
||||
lang = "zh",
|
||||
isNsfw = true,
|
||||
className = "Dimanba",
|
||||
sourceName = "滴漫吧",
|
||||
overrideVersionCode = 0,
|
||||
),
|
||||
)
|
||||
|
||||
override fun createAll() {
|
||||
val userDir = System.getProperty("user.dir")!!
|
||||
sources.forEach {
|
||||
val themeClass = if (it.isNsfw) "MCCMSNsfw" else themeClass
|
||||
ThemeSourceGenerator.createGradleProject(it, themePkg, themeClass, baseVersionCode, userDir)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MCCMSGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.util.asJsoup
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.select.Evaluator
|
||||
|
||||
open class MCCMSNsfw(
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
lang: String = "zh",
|
||||
) : MCCMSWeb(name, baseUrl, lang, hasCategoryPage = false) {
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
if (query.isNotBlank()) {
|
||||
GET("$baseUrl/search/$query/$page", pcHeaders)
|
||||
} else {
|
||||
super.searchMangaRequest(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = parseListing(response.asJsoup())
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
GET(baseUrl + chapter.url, headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val container = response.asJsoup().selectFirst(Evaluator.Class("comic-list"))!!
|
||||
return container.select(Evaluator.Tag("img")).mapIndexed { index, img ->
|
||||
Page(index, imageUrl = img.attr("src"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
|
||||
// https://github.com/tachiyomiorg/tachiyomi-extensions/blob/e0b4fcbce8aa87742da22e7fa60b834313f53533/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mccms/MCCMS.kt
|
||||
open class MCCMSWeb(
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
lang: String = "zh",
|
||||
hasCategoryPage: Boolean = true,
|
||||
) : MCCMS(name, baseUrl, lang, hasCategoryPage) {
|
||||
|
||||
protected open fun parseListing(document: Document): MangasPage {
|
||||
val mangas = document.select(Evaluator.Class("common-comic-item")).map {
|
||||
SManga.create().apply {
|
||||
val titleElement = it.selectFirst(Evaluator.Class("comic__title"))!!.child(0)
|
||||
url = titleElement.attr("href")
|
||||
title = titleElement.ownText()
|
||||
thumbnail_url = it.selectFirst(Evaluator.Tag("img"))!!.attr("data-original")
|
||||
}.cleanup()
|
||||
}
|
||||
val hasNextPage = run { // default pagination
|
||||
val buttons = document.selectFirst(Evaluator.Id("Pagination"))!!.select(Evaluator.Tag("a"))
|
||||
val count = buttons.size
|
||||
// Next page != Last page
|
||||
buttons[count - 1].attr("href") != buttons[count - 2].attr("href")
|
||||
}
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/category/order/hits/page/$page", pcHeaders)
|
||||
|
||||
override fun popularMangaParse(response: Response) = parseListing(response.asJsoup())
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/category/order/addtime/page/$page", pcHeaders)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = parseListing(response.asJsoup())
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
if (query.isNotBlank()) {
|
||||
val url = "$baseUrl/index.php/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("key", query)
|
||||
.toString()
|
||||
GET(url, pcHeaders)
|
||||
} else {
|
||||
val url = buildString {
|
||||
append(baseUrl).append("/category/")
|
||||
filters.filterIsInstance<MCCMSFilter>().map { it.query }.filter { it.isNotEmpty() }
|
||||
.joinTo(this, "/")
|
||||
append("/page/").append(page)
|
||||
}
|
||||
GET(url, pcHeaders)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (document.selectFirst(Evaluator.Id("code-div")) != null) {
|
||||
val manga = SManga.create().apply {
|
||||
url = "/index.php/search"
|
||||
title = "验证码"
|
||||
description = "请点击 WebView 按钮输入验证码,完成后返回重新搜索"
|
||||
initialized = true
|
||||
}
|
||||
return MangasPage(listOf(manga), false)
|
||||
}
|
||||
val result = parseListing(document)
|
||||
if (document.location().contains("search")) {
|
||||
return MangasPage(result.mangas, false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
if (manga.url == "/index.php/search") return Observable.just(manga)
|
||||
return client.newCall(GET(baseUrl + manga.url, pcHeaders)).asObservableSuccess().map { response ->
|
||||
mangaDetailsParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return run {
|
||||
SManga.create().apply {
|
||||
val document = response.asJsoup().selectFirst(Evaluator.Class("de-info__box"))!!
|
||||
title = document.selectFirst(Evaluator.Class("comic-title"))!!.ownText()
|
||||
thumbnail_url = document.selectFirst(Evaluator.Tag("img"))!!.attr("src")
|
||||
author = document.selectFirst(Evaluator.Class("name"))!!.text()
|
||||
genre = document.selectFirst(Evaluator.Class("comic-status"))!!.select(Evaluator.Tag("a")).joinToString { it.ownText() }
|
||||
description = document.selectFirst(Evaluator.Class("intro-total"))!!.text()
|
||||
}.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
if (manga.url == "/index.php/search") return Observable.just(emptyList())
|
||||
return client.newCall(GET(baseUrl + manga.url, pcHeaders)).asObservableSuccess().map { response ->
|
||||
chapterListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return run {
|
||||
response.asJsoup().selectFirst(Evaluator.Class("chapter__list-box"))!!.children().map {
|
||||
val link = it.child(0)
|
||||
SChapter.create().apply {
|
||||
url = link.attr("href")
|
||||
name = link.ownText()
|
||||
}
|
||||
}.asReversed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
fetchGenres()
|
||||
return getWebFilters(genreData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmrcms
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class MMRCMS(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
sourceInfo: String = "",
|
||||
) : HttpSource() {
|
||||
open val jsonData = if (sourceInfo == "") {
|
||||
SourceData.giveMetaData(baseUrl)
|
||||
} else {
|
||||
sourceInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a List of JSON sources into a list of `MyMangaReaderCMSSource`s
|
||||
*
|
||||
* Example JSON :
|
||||
* ```
|
||||
* {
|
||||
* "language": "en",
|
||||
* "name": "Example manga reader",
|
||||
* "base_url": "https://example.com",
|
||||
* "supports_latest": true,
|
||||
* "item_url": "https://example.com/manga/",
|
||||
* "categories": [
|
||||
* {"id": "stuff", "name": "Stuff"},
|
||||
* {"id": "test", "name": "Test"}
|
||||
* ],
|
||||
* "tags": [
|
||||
* {"id": "action", "name": "Action"},
|
||||
* {"id": "adventure", "name": "Adventure"}
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
*
|
||||
* Sources that do not supports tags may use `null` instead of a list of json objects
|
||||
*
|
||||
* @param sourceString The List of JSON strings 1 entry = one source
|
||||
* @return The list of parsed sources
|
||||
*
|
||||
* isNSFW, language, name and base_url are no longer needed as that is handled by multisrc
|
||||
* supports_latest, item_url, categories and tags are still needed
|
||||
*
|
||||
*
|
||||
*/
|
||||
private val json: Json by injectLazy()
|
||||
val jsonObject = json.decodeFromString<JsonObject>(jsonData)
|
||||
override val supportsLatest = jsonObject["supports_latest"]!!.jsonPrimitive.boolean
|
||||
open val itemUrl = jsonObject["item_url"]!!.jsonPrimitive.content
|
||||
open val categoryMappings = mapToPairs(jsonObject["categories"]!!.jsonArray)
|
||||
open var tagMappings = jsonObject["tags"]?.jsonArray?.let { mapToPairs(it) } ?: emptyList()
|
||||
|
||||
/**
|
||||
* Map an array of JSON objects to pairs. Each JSON object must have
|
||||
* the following properties:
|
||||
*
|
||||
* id: first item in pair
|
||||
* name: second item in pair
|
||||
*
|
||||
* @param array The array to process
|
||||
* @return The new list of pairs
|
||||
*/
|
||||
open fun mapToPairs(array: JsonArray): List<Pair<String, String>> = array.map {
|
||||
it as JsonObject
|
||||
|
||||
it["id"]!!.jsonPrimitive.content to it["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
|
||||
private val itemUrlPath = Uri.parse(itemUrl).pathSegments.firstOrNull()
|
||||
private val parsedBaseUrl = Uri.parse(baseUrl)
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.connectTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.MINUTES)
|
||||
.writeTimeout(1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/filterList?page=$page&sortBy=views&asc=false", headers)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url: Uri.Builder
|
||||
when {
|
||||
query.isNotBlank() -> {
|
||||
url = Uri.parse("$baseUrl/search")!!.buildUpon()
|
||||
url.appendQueryParameter("query", query)
|
||||
}
|
||||
else -> {
|
||||
url = Uri.parse("$baseUrl/filterList?page=$page")!!.buildUpon()
|
||||
filters.filterIsInstance<UriFilter>()
|
||||
.forEach { it.addToUri(url) }
|
||||
}
|
||||
}
|
||||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the usual search engine isn't available, search through the list of titles with this
|
||||
*/
|
||||
private fun selfSearch(query: String): Observable<MangasPage> {
|
||||
return client.newCall(GET("$baseUrl/changeMangaList?type=text", headers))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val mangas = response.asJsoup().select("ul.manga-list a").toList()
|
||||
.filter { it.text().contains(query, ignoreCase = true) }
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
title = it.text()
|
||||
setUrlWithoutDomain(it.attr("abs:href"))
|
||||
thumbnail_url = coverGuess(null, it.attr("abs:href"))
|
||||
}
|
||||
}
|
||||
MangasPage(mangas, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/latest-release?page=$page", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response) = internalMangaParse(response)
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
return if (listOf("query", "q").any { it in response.request.url.queryParameterNames }) {
|
||||
// If a search query was specified, use search instead!
|
||||
val jsonArray = json.decodeFromString<JsonObject>(response.body.string()).let {
|
||||
it["suggestions"]!!.jsonArray
|
||||
}
|
||||
MangasPage(
|
||||
jsonArray
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
val segment = it.jsonObject["data"]!!.jsonPrimitive.content
|
||||
url = getUrlWithoutBaseUrl(itemUrl + segment)
|
||||
title = it.jsonObject["value"]!!.jsonPrimitive.content
|
||||
|
||||
// Guess thumbnails
|
||||
// thumbnail_url = "$baseUrl/uploads/manga/$segment/cover/cover_250x350.jpg"
|
||||
}
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
internalMangaParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private val latestTitles = mutableSetOf<String>()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
if (document.location().contains("page=1")) latestTitles.clear()
|
||||
|
||||
val mangas = document.select(latestUpdatesSelector())
|
||||
.let { elements ->
|
||||
when {
|
||||
// List layout (most sources)
|
||||
elements.select("a").firstOrNull()?.hasText() == true -> elements.map { latestUpdatesFromElement(it, "a") }
|
||||
// Grid layout (e.g. MangaID)
|
||||
else -> document.select(gridLatestUpdatesSelector()).map { gridLatestUpdatesFromElement(it) }
|
||||
}
|
||||
}
|
||||
.filterNotNull()
|
||||
|
||||
return MangasPage(mangas, document.select(latestUpdatesNextPageSelector()) != null)
|
||||
}
|
||||
private fun latestUpdatesSelector() = "div.mangalist div.manga-item"
|
||||
private fun latestUpdatesNextPageSelector() = "a[rel=next]"
|
||||
protected open fun latestUpdatesFromElement(element: Element, urlSelector: String): SManga? {
|
||||
return element.select(urlSelector).first()!!.let { titleElement ->
|
||||
if (titleElement.text() in latestTitles) {
|
||||
null
|
||||
} else {
|
||||
latestTitles.add(titleElement.text())
|
||||
SManga.create().apply {
|
||||
url = titleElement.attr("abs:href").substringAfter(baseUrl) // intentionally not using setUrlWithoutDomain
|
||||
title = titleElement.text().trim()
|
||||
thumbnail_url = "$baseUrl/uploads/manga/${url.substringAfterLast('/')}/cover/cover_250x350.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun gridLatestUpdatesSelector() = "div.mangalist div.manga-item, div.grid-manga tr"
|
||||
protected open fun gridLatestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
|
||||
element.select("a.chart-title").let {
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
title = it.text()
|
||||
}
|
||||
thumbnail_url = element.select("img").attr("abs:src")
|
||||
}
|
||||
|
||||
protected open fun internalMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val internalMangaSelector = when (name) {
|
||||
"Utsukushii" -> "div.content div.col-sm-6"
|
||||
else -> "div[class^=col-sm], div.col-xs-6"
|
||||
}
|
||||
return MangasPage(
|
||||
document.select(internalMangaSelector).map {
|
||||
SManga.create().apply {
|
||||
val urlElement = it.getElementsByClass("chart-title")
|
||||
if (urlElement.size == 0) {
|
||||
url = getUrlWithoutBaseUrl(it.select("a").attr("href"))
|
||||
title = it.select("div.caption").text()
|
||||
it.select("div.caption div").text().let { if (it.isNotEmpty()) title = title.substringBefore(it) } // To clean submanga's titles without breaking hentaishark's
|
||||
} else {
|
||||
url = getUrlWithoutBaseUrl(urlElement.attr("href"))
|
||||
title = urlElement.text().trim()
|
||||
}
|
||||
|
||||
it.select("img").let { img ->
|
||||
thumbnail_url = when {
|
||||
it.hasAttr("data-background-image") -> it.attr("data-background-image") // Utsukushii
|
||||
img.hasAttr("data-src") -> coverGuess(img.attr("abs:data-src"), url)
|
||||
else -> coverGuess(img.attr("abs:src"), url)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
document.select(".pagination a[rel=next]").isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
// Guess thumbnails on broken websites
|
||||
fun coverGuess(url: String?, mangaUrl: String): String? {
|
||||
return if (url?.endsWith("no-image.png") == true) {
|
||||
"$baseUrl/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg"
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
fun getUrlWithoutBaseUrl(newUrl: String): String {
|
||||
val parsedNewUrl = Uri.parse(newUrl)
|
||||
val newPathSegments = parsedNewUrl.pathSegments.toMutableList()
|
||||
|
||||
for (i in parsedBaseUrl.pathSegments) {
|
||||
if (i.trim().equals(newPathSegments.first(), true)) {
|
||||
newPathSegments.removeAt(0)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val builtUrl = parsedNewUrl.buildUpon().path("/")
|
||||
newPathSegments.forEach { builtUrl.appendPath(it) }
|
||||
|
||||
var out = builtUrl.build().encodedPath!!
|
||||
if (parsedNewUrl.encodedQuery != null) {
|
||||
out += "?" + parsedNewUrl.encodedQuery
|
||||
}
|
||||
if (parsedNewUrl.encodedFragment != null) {
|
||||
out += "#" + parsedNewUrl.encodedFragment
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||
val document = response.asJsoup()
|
||||
document.select("h2.listmanga-header, h2.widget-title").firstOrNull()?.text()?.trim()?.let { title = it }
|
||||
thumbnail_url = coverGuess(document.select(".row [class^=img-responsive]").firstOrNull()?.attr("abs:src"), document.location())
|
||||
description = document.select(".row .well p").text().trim()
|
||||
|
||||
val detailAuthor = setOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy")
|
||||
val detailArtist = setOf("artist(s)", "artiste(s)", "sanatçi(lar)", "artista(s)", "artist(s)/ilustrator", "الرسام", "seniman", "rysownik/rysownicy")
|
||||
val detailGenre = setOf("categories", "categorías", "catégories", "ジャンル", "kategoriler", "categorias", "kategorie", "التصنيفات", "жанр", "kategori", "tagi")
|
||||
val detailStatus = setOf("status", "statut", "estado", "状態", "durum", "الحالة", "статус")
|
||||
val detailStatusComplete = setOf("complete", "مكتملة", "complet", "completo", "zakończone", "concluído")
|
||||
val detailStatusOngoing = setOf("ongoing", "مستمرة", "en cours", "em lançamento", "prace w toku", "ativo", "em andamento")
|
||||
val detailDescription = setOf("description", "resumen")
|
||||
|
||||
for (element in document.select(".row .dl-horizontal dt")) {
|
||||
when (element.text().trim().lowercase().removeSuffix(":")) {
|
||||
in detailAuthor -> author = element.nextElementSibling()!!.text()
|
||||
in detailArtist -> artist = element.nextElementSibling()!!.text()
|
||||
in detailGenre -> genre = element.nextElementSibling()!!.select("a").joinToString {
|
||||
it.text().trim()
|
||||
}
|
||||
in detailStatus -> status = when (element.nextElementSibling()!!.text().trim().lowercase()) {
|
||||
in detailStatusComplete -> SManga.COMPLETED
|
||||
in detailStatusOngoing -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
// When details are in a .panel instead of .row (ES sources)
|
||||
for (element in document.select("div.panel span.list-group-item")) {
|
||||
when (element.select("b").text().lowercase().substringBefore(":")) {
|
||||
in detailAuthor -> author = element.select("b + a").text()
|
||||
in detailArtist -> artist = element.select("b + a").text()
|
||||
in detailGenre -> genre = element.getElementsByTag("a").joinToString {
|
||||
it.text().trim()
|
||||
}
|
||||
in detailStatus -> status = when (element.select("b + span.label").text().lowercase()) {
|
||||
in detailStatusComplete -> SManga.COMPLETED
|
||||
in detailStatusOngoing -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
in detailDescription -> description = element.ownText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* Overriden to allow for null chapters
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return document.select(chapterListSelector()).mapNotNull { nullableChapterFromElement(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
|
||||
*/
|
||||
protected open fun chapterListSelector() = "ul[class^=chapters] > li:not(.btn), table.table tr"
|
||||
// Some websites add characters after "chapters" thus the need of checking classes that starts with "chapters"
|
||||
|
||||
/**
|
||||
* titleWrapper can have multiple "a" elements, filter to the first that contains letters (i.e. not "" or # as is possible)
|
||||
*/
|
||||
private val urlRegex = Regex("""[a-zA-z]""")
|
||||
|
||||
/**
|
||||
* Returns a chapter from the given element.
|
||||
*
|
||||
* @param element an element obtained from [chapterListSelector].
|
||||
*/
|
||||
protected open fun nullableChapterFromElement(element: Element): SChapter? {
|
||||
val chapter = SChapter.create()
|
||||
|
||||
try {
|
||||
val titleWrapper = element.select("[class^=chapter-title-rtl]").first()!!
|
||||
// Some websites add characters after "..-rtl" thus the need of checking classes that starts with that
|
||||
val url = titleWrapper.getElementsByTag("a")
|
||||
.first { it.attr("href").contains(urlRegex) }
|
||||
.attr("href")
|
||||
|
||||
// Ensure chapter actually links to a manga
|
||||
// Some websites use the chapters box to link to post announcements
|
||||
// The check is skipped if mangas are stored in the root of the website (ex '/one-piece' without a segment like '/manga/one-piece')
|
||||
if (itemUrlPath != null && !Uri.parse(url).pathSegments.firstOrNull().equals(itemUrlPath, true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
chapter.url = getUrlWithoutBaseUrl(url)
|
||||
chapter.name = titleWrapper.text()
|
||||
|
||||
// Parse date
|
||||
val dateText = element.getElementsByClass("date-chapter-title-rtl").text().trim()
|
||||
chapter.date_upload = parseDate(dateText)
|
||||
|
||||
return chapter
|
||||
} catch (e: NullPointerException) {
|
||||
// For chapter list in a table
|
||||
if (element.select("td").hasText()) {
|
||||
element.select("td a").let {
|
||||
chapter.setUrlWithoutDomain(it.attr("href"))
|
||||
chapter.name = it.text()
|
||||
}
|
||||
val tableDateText = element.select("td + td").text()
|
||||
chapter.date_upload = parseDate(tableDateText)
|
||||
|
||||
return chapter
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseDate(dateText: String): Long {
|
||||
return try {
|
||||
DATE_FORMAT.parse(dateText)?.time ?: 0
|
||||
} catch (e: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response) = response.asJsoup().select("#all > .img-responsive")
|
||||
.mapIndexed { i, e ->
|
||||
var url = (if (e.hasAttr("data-src")) e.attr("abs:data-src") else e.attr("abs:src")).trim()
|
||||
|
||||
Page(i, response.request.url.toString(), url)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Unused method called!")
|
||||
|
||||
private fun getInitialFilterList() = listOf<Filter<*>>(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
AuthorFilter(),
|
||||
UriSelectFilter(
|
||||
"Category",
|
||||
"cat",
|
||||
arrayOf(
|
||||
"" to "Any",
|
||||
*categoryMappings.toTypedArray(),
|
||||
),
|
||||
),
|
||||
UriSelectFilter(
|
||||
"Begins with",
|
||||
"alpha",
|
||||
arrayOf(
|
||||
"" to "Any",
|
||||
*"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map {
|
||||
Pair(it.toString(), it.toString())
|
||||
}.toTypedArray(),
|
||||
),
|
||||
),
|
||||
SortFilter(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList(): FilterList {
|
||||
return when {
|
||||
tagMappings != emptyList<Pair<String, String>>() -> {
|
||||
FilterList(
|
||||
getInitialFilterList() + UriSelectFilter(
|
||||
"Tag",
|
||||
"tag",
|
||||
arrayOf(
|
||||
"" to "Any",
|
||||
*tagMappings.toTypedArray(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> FilterList(getInitialFilterList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||
*/
|
||||
// vals: <name, display>
|
||||
open class UriSelectFilter(
|
||||
displayName: String,
|
||||
private val uriParam: String,
|
||||
private val vals: Array<Pair<String, String>>,
|
||||
private val firstIsUnspecified: Boolean = true,
|
||||
defaultValue: Int = 0,
|
||||
) :
|
||||
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||
override fun addToUri(uri: Uri.Builder) {
|
||||
if (state != 0 || !firstIsUnspecified) {
|
||||
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AuthorFilter : Filter.Text("Author"), UriFilter {
|
||||
override fun addToUri(uri: Uri.Builder) {
|
||||
uri.appendQueryParameter("author", state)
|
||||
}
|
||||
}
|
||||
|
||||
class SortFilter :
|
||||
Filter.Sort(
|
||||
"Sort",
|
||||
sortables.map { it.second }.toTypedArray(),
|
||||
Selection(0, true),
|
||||
),
|
||||
UriFilter {
|
||||
override fun addToUri(uri: Uri.Builder) {
|
||||
uri.appendQueryParameter("sortBy", sortables[state!!.index].first)
|
||||
uri.appendQueryParameter("asc", state!!.ascending.toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val sortables = arrayOf(
|
||||
"name" to "Name",
|
||||
"views" to "Popularity",
|
||||
"last_release" to "Last update",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a filter that is able to modify a URI.
|
||||
*/
|
||||
interface UriFilter {
|
||||
fun addToUri(uri: Uri.Builder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT = SimpleDateFormat("d MMM. yyyy", Locale.US)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmrcms
|
||||
|
||||
import generator.ThemeSourceData.SingleLang
|
||||
import generator.ThemeSourceGenerator
|
||||
|
||||
class MMRCMSGenerator : ThemeSourceGenerator {
|
||||
|
||||
override val themePkg = "mmrcms"
|
||||
|
||||
override val themeClass = "MMRCMS"
|
||||
|
||||
override val baseVersionCode = 6
|
||||
|
||||
override val sources = listOf(
|
||||
SingleLang("مانجا اون لاين", "https://onma.top", "ar", className = "onma"),
|
||||
SingleLang("Read Comics Online", "https://readcomicsonline.ru", "en"),
|
||||
SingleLang("Fallen Angels", "https://manga.fascans.com", "en", overrideVersionCode = 2),
|
||||
SingleLang("Scan FR", "https://www.scan-fr.org", "fr", overrideVersionCode = 2),
|
||||
SingleLang("Scan VF", "https://www.scan-vf.net", "fr", overrideVersionCode = 1),
|
||||
SingleLang("Komikid", "https://www.komikid.com", "id"),
|
||||
SingleLang("Mangadoor", "https://mangadoor.com", "es", overrideVersionCode = 1),
|
||||
SingleLang("Utsukushii", "https://manga.utsukushii-bg.com", "bg", overrideVersionCode = 1),
|
||||
SingleLang("Phoenix-Scans", "https://phoenix-scans.pl", "pl", className = "PhoenixScans", overrideVersionCode = 1),
|
||||
SingleLang("Lelscan-VF", "https://lelscanvf.cc", "fr", className = "LelscanVF", overrideVersionCode = 2),
|
||||
SingleLang("AnimaRegia", "https://animaregia.net", "pt-BR", overrideVersionCode = 4),
|
||||
SingleLang("MangaID", "https://mangaid.click", "id", overrideVersionCode = 1),
|
||||
SingleLang("Jpmangas", "https://jpmangas.xyz", "fr", overrideVersionCode = 2),
|
||||
SingleLang("Manga-FR", "https://manga-fr.cc", "fr", className = "MangaFR", overrideVersionCode = 2),
|
||||
SingleLang("Manga-Scan", "https://mangascan.cc", "fr", className = "MangaScan", overrideVersionCode = 2),
|
||||
SingleLang("Ama Scans", "https://amascan.com", "pt-BR", isNsfw = true, overrideVersionCode = 2),
|
||||
SingleLang("Bentoscan", "https://bentoscan.com", "fr"),
|
||||
// NOTE: THIS SOURCE CONTAINS A CUSTOM LANGUAGE SYSTEM (which will be ignored)!
|
||||
SingleLang("HentaiShark", "https://www.hentaishark.com", "all", isNsfw = true),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MMRCMSGenerator().createAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmrcms
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.io.PrintWriter
|
||||
import java.security.cert.CertificateException
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* This class generates the sources for MMRCMS.
|
||||
* Credit to nulldev for writing the original shell script
|
||||
*
|
||||
* CMS: https://getcyberworks.com/product/manga-reader-cms/
|
||||
*/
|
||||
class MMRCMSJsonGen {
|
||||
// private var preRunTotal: String
|
||||
|
||||
init {
|
||||
System.setProperty("https.protocols", "TLSv1,TLSv1.1,TLSv1.2,TLSv1.3")
|
||||
// preRunTotal = Regex("""-> (\d+)""").findAll(File(relativePath).readText(Charsets.UTF_8)).last().groupValues[1]
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
fun generate() {
|
||||
val buffer = StringBuffer()
|
||||
val dateTime = ZonedDateTime.now()
|
||||
val formattedDate = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||
buffer.append("package eu.kanade.tachiyomi.multisrc.mmrcms")
|
||||
buffer.append("\n\n// GENERATED FILE, DO NOT MODIFY!\n// Generated $formattedDate\n\n")
|
||||
buffer.append("object SourceData {\n")
|
||||
buffer.append(" fun giveMetaData(url: String) = when (url) {\n")
|
||||
var number = 1
|
||||
sources.forEach {
|
||||
println("Generating ${it.name}")
|
||||
try {
|
||||
val advancedSearchDocument = getDocument("${it.baseUrl}/advanced-search", false)
|
||||
|
||||
var parseCategories = mutableListOf<Map<String, String>>()
|
||||
if (advancedSearchDocument != null) {
|
||||
parseCategories = parseCategories(advancedSearchDocument)
|
||||
}
|
||||
|
||||
val homePageDocument = getDocument(it.baseUrl)
|
||||
|
||||
val itemUrl = getItemUrl(homePageDocument, it.baseUrl)
|
||||
|
||||
var prefix = itemUrl.substringAfterLast("/").substringBeforeLast("/")
|
||||
|
||||
// Sometimes itemUrl is the root of the website, and thus the prefix found is the website address.
|
||||
// In this case, we set the default prefix as "manga".
|
||||
if (prefix.startsWith("www") || prefix.startsWith("wwv")) {
|
||||
prefix = "manga"
|
||||
}
|
||||
|
||||
val mangaListDocument = getDocument("${it.baseUrl}/$prefix-list")!!
|
||||
|
||||
if (parseCategories.isEmpty()) {
|
||||
parseCategories = parseCategories(mangaListDocument)
|
||||
}
|
||||
|
||||
val tags = parseTags(mangaListDocument)
|
||||
|
||||
val source = SourceDataModel(
|
||||
name = it.name,
|
||||
base_url = it.baseUrl,
|
||||
supports_latest = supportsLatest(it.baseUrl),
|
||||
item_url = "$itemUrl/",
|
||||
categories = parseCategories,
|
||||
tags = if (tags.size in 1..49) tags else null,
|
||||
)
|
||||
|
||||
if (!itemUrl.startsWith(it.baseUrl)) println("**Note: ${it.name} URL does not match! Check for changes: \n ${it.baseUrl} vs $itemUrl")
|
||||
|
||||
buffer.append(" \"${it.baseUrl}\" -> \"\"\"${Json.encodeToString(source)}\"\"\"\n")
|
||||
number++
|
||||
} catch (e: Exception) {
|
||||
println("error generating source ${it.name} ${e.printStackTrace()}")
|
||||
}
|
||||
}
|
||||
|
||||
buffer.append(" else -> \"\"\n")
|
||||
buffer.append(" }\n")
|
||||
buffer.append("}\n")
|
||||
// println("Pre-run sources: $preRunTotal")
|
||||
println("Post-run sources: ${number - 1}")
|
||||
PrintWriter(relativePath).use {
|
||||
it.write(buffer.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDocument(url: String, printStackTrace: Boolean = true): Document? {
|
||||
val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
|
||||
try {
|
||||
val request = Request.Builder().url(url)
|
||||
getOkHttpClient().newCall(request.build()).execute().let { response ->
|
||||
// Bypass Cloudflare ("Please wait 5 seconds" page)
|
||||
if (response.code == 503 && response.header("Server") in serverCheck) {
|
||||
var cookie = "${response.header("Set-Cookie")!!.substringBefore(";")}; "
|
||||
Jsoup.parse(response.body.string()).let { document ->
|
||||
val path = document.select("[id=\"challenge-form\"]").attr("action")
|
||||
val chk = document.select("[name=\"s\"]").attr("value")
|
||||
getOkHttpClient().newCall(Request.Builder().url("$url/$path?s=$chk").build()).execute().let { solved ->
|
||||
cookie += solved.header("Set-Cookie")!!.substringBefore(";")
|
||||
request.addHeader("Cookie", cookie).build().let {
|
||||
return Jsoup.parse(getOkHttpClient().newCall(it).execute().body.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.code == 200) {
|
||||
return Jsoup.parse(response.body.string())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (printStackTrace) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseTags(mangaListDocument: Document): List<Map<String, String>> {
|
||||
val elements = mangaListDocument.select("div.tag-links a")
|
||||
return elements.map {
|
||||
mapOf(
|
||||
"id" to it.attr("href").substringAfterLast("/"),
|
||||
"name" to it.text(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getItemUrl(document: Document?, url: String): String {
|
||||
document ?: throw Exception("Couldn't get document for: $url")
|
||||
return document.toString().substringAfter("showURL = \"").substringAfter("showURL=\"").substringBefore("/SELECTION\";")
|
||||
|
||||
// Some websites like mangasyuri use javascript minifiers, and thus "showURL = " becomes "showURL="https://mangasyuri.net/manga/SELECTION""
|
||||
// (without spaces). Hence the double substringAfter.
|
||||
}
|
||||
|
||||
private fun supportsLatest(third: String): Boolean {
|
||||
val document = getDocument("$third/latest-release?page=1", false) ?: return false
|
||||
return document.select("div.mangalist div.manga-item a, div.grid-manga tr").isNotEmpty()
|
||||
}
|
||||
|
||||
private fun parseCategories(document: Document): MutableList<Map<String, String>> {
|
||||
val elements = document.select("select[name^=categories] option, a.category")
|
||||
return elements.mapIndexed { index, element ->
|
||||
mapOf(
|
||||
"id" to (index + 1).toString(),
|
||||
"name" to element.text(),
|
||||
)
|
||||
}.toMutableList()
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun getOkHttpClient(): OkHttpClient {
|
||||
// Create all-trusting host name verifier
|
||||
val trustAllCerts = arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
|
||||
return arrayOf()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Install the all-trusting trust manager
|
||||
val sc = SSLContext.getInstance("SSL").apply {
|
||||
init(null, trustAllCerts, java.security.SecureRandom())
|
||||
}
|
||||
val sslSocketFactory = sc.socketFactory
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.connectTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.MINUTES)
|
||||
.writeTimeout(1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SourceDataModel(
|
||||
val name: String,
|
||||
val base_url: String,
|
||||
val supports_latest: Boolean,
|
||||
val item_url: String,
|
||||
val categories: List<Map<String, String>>,
|
||||
val tags: List<Map<String, String>>? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val sources = MMRCMSGenerator().sources
|
||||
|
||||
val relativePath = System.getProperty("user.dir")!! + "/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/SourceData.kt"
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
MMRCMSJsonGen().generate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmrcms
|
||||
|
||||
// GENERATED FILE, DO NOT MODIFY!
|
||||
// Generated Sun, 16 Apr 2022 14:18:00 GMT
|
||||
|
||||
object SourceData {
|
||||
fun giveMetaData(url: String) = when (url) {
|
||||
"https://onma.top" -> """{"name":"مانجا اون لاين","base_url":"https://onma.top","supports_latest":true,"item_url":"https://onma.top/manga/","categories":[{"id":"1","name":"أكشن"},{"id":"2","name":"مغامرة"},{"id":"3","name":"كوميدي"},{"id":"4","name":"شياطين"},{"id":"5","name":"دراما"},{"id":"6","name":"إيتشي"},{"id":"7","name":"خيال"},{"id":"8","name":"انحراف جنسي"},{"id":"9","name":"حريم"},{"id":"10","name":"تاريخي"},{"id":"11","name":"رعب"},{"id":"12","name":"جوسي"},{"id":"13","name":"فنون قتالية"},{"id":"14","name":"ناضج"},{"id":"15","name":"ميكا"},{"id":"16","name":"غموض"},{"id":"17","name":"وان شوت"},{"id":"18","name":"نفسي"},{"id":"19","name":"رومنسي"},{"id":"20","name":"حياة مدرسية"},{"id":"21","name":"خيال علمي"},{"id":"22","name":"سينين"},{"id":"23","name":"شوجو"},{"id":"24","name":"شوجو أي"},{"id":"25","name":"شونين"},{"id":"26","name":"شونين أي"},{"id":"27","name":"شريحة من الحياة"},{"id":"28","name":"رياضة"},{"id":"29","name":"خارق للطبيعة"},{"id":"30","name":"مأساة"},{"id":"31","name":"مصاصي الدماء"},{"id":"32","name":"سحر"},{"id":"33","name":"ويب تون"},{"id":"34","name":"دوجينشي"}]}"""
|
||||
"https://readcomicsonline.ru" -> """{"name":"Read Comics Online","base_url":"https://readcomicsonline.ru","supports_latest":true,"item_url":"https://readcomicsonline.ru/comic/","categories":[{"id":"1","name":"One Shots \u0026 TPBs"},{"id":"2","name":"DC Comics"},{"id":"3","name":"Marvel Comics"},{"id":"4","name":"Boom Studios"},{"id":"5","name":"Dynamite"},{"id":"6","name":"Rebellion"},{"id":"7","name":"Dark Horse"},{"id":"8","name":"IDW"},{"id":"9","name":"Archie"},{"id":"10","name":"Graphic India"},{"id":"11","name":"Darby Pop"},{"id":"12","name":"Oni Press"},{"id":"13","name":"Icon Comics"},{"id":"14","name":"United Plankton"},{"id":"15","name":"Udon"},{"id":"16","name":"Image Comics"},{"id":"17","name":"Valiant"},{"id":"18","name":"Vertigo"},{"id":"19","name":"Devils Due"},{"id":"20","name":"Aftershock Comics"},{"id":"21","name":"Antartic Press"},{"id":"22","name":"Action Lab"},{"id":"23","name":"American Mythology"},{"id":"24","name":"Zenescope"},{"id":"25","name":"Top Cow"},{"id":"26","name":"Hermes Press"},{"id":"27","name":"451"},{"id":"28","name":"Black Mask"},{"id":"29","name":"Chapterhouse Comics"},{"id":"30","name":"Red 5"},{"id":"31","name":"Heavy Metal"},{"id":"32","name":"Bongo"},{"id":"33","name":"Top Shelf"},{"id":"34","name":"Bubble"},{"id":"35","name":"Boundless"},{"id":"36","name":"Avatar Press"},{"id":"37","name":"Space Goat Productions"},{"id":"38","name":"BroadSword Comics"},{"id":"39","name":"AAM-Markosia"},{"id":"40","name":"Fantagraphics"},{"id":"41","name":"Aspen"},{"id":"42","name":"American Gothic Press"},{"id":"43","name":"Vault"},{"id":"44","name":"215 Ink"},{"id":"45","name":"Abstract Studio"},{"id":"46","name":"Albatross"},{"id":"47","name":"ARH Comix"},{"id":"48","name":"Legendary Comics"},{"id":"49","name":"Monkeybrain"},{"id":"50","name":"Joe Books"},{"id":"51","name":"MAD"},{"id":"52","name":"Comics Experience"},{"id":"53","name":"Alterna Comics"},{"id":"54","name":"Lion Forge"},{"id":"55","name":"Benitez"},{"id":"56","name":"Storm King"},{"id":"57","name":"Sucker"},{"id":"58","name":"Amryl Entertainment"},{"id":"59","name":"Ahoy Comics"},{"id":"60","name":"Mad Cave"},{"id":"61","name":"Coffin Comics"},{"id":"62","name":"Magnetic Press"},{"id":"63","name":"Ablaze"},{"id":"64","name":"Europe Comics"},{"id":"65","name":"Humanoids"},{"id":"66","name":"TKO"},{"id":"67","name":"Soleil"},{"id":"68","name":"SAF Comics"},{"id":"69","name":"Scholastic"},{"id":"70","name":"Upshot"},{"id":"71","name":"Stranger Comics"},{"id":"72","name":"Inverse"},{"id":"73","name":"Virus"}]}"""
|
||||
"https://manga.fascans.com" -> """{"name":"Fallen Angels","base_url":"https://manga.fascans.com","supports_latest":true,"item_url":"https://manga.fascans.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"},{"id":"33","name":"4-Koma"},{"id":"34","name":"Cooking"}]}"""
|
||||
"https://zahard.xyz" -> """{"name":"Zahard","base_url":"https://zahard.xyz","supports_latest":true,"item_url":"https://zahard.xyz/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}"""
|
||||
"https://www.scan-fr.org" -> """{"name":"Scan FR","base_url":"https://www.scan-fr.org","supports_latest":true,"item_url":"https://www.scan-fr.org/manga/","categories":[{"id":"1","name":"Comedy"},{"id":"2","name":"Doujinshi"},{"id":"3","name":"Drama"},{"id":"4","name":"Ecchi"},{"id":"5","name":"Fantasy"},{"id":"6","name":"Gender Bender"},{"id":"7","name":"Josei"},{"id":"8","name":"Mature"},{"id":"9","name":"Mecha"},{"id":"10","name":"Mystery"},{"id":"11","name":"One Shot"},{"id":"12","name":"Psychological"},{"id":"13","name":"Romance"},{"id":"14","name":"School Life"},{"id":"15","name":"Sci-fi"},{"id":"16","name":"Seinen"},{"id":"17","name":"Shoujo"},{"id":"18","name":"Shoujo Ai"},{"id":"19","name":"Shounen"},{"id":"20","name":"Shounen Ai"},{"id":"21","name":"Slice of Life"},{"id":"22","name":"Sports"},{"id":"23","name":"Supernatural"},{"id":"24","name":"Tragedy"},{"id":"25","name":"Yaoi"},{"id":"26","name":"Yuri"},{"id":"27","name":"Comics"},{"id":"28","name":"Autre"},{"id":"29","name":"BD Occidentale"},{"id":"30","name":"Manhwa"},{"id":"31","name":"Action"},{"id":"32","name":"Aventure"}]}"""
|
||||
"https://www.scan-vf.net" -> """{"name":"Scan VF","base_url":"https://www.scan-vf.net","supports_latest":true,"item_url":"https://www.scan-vf.net/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}"""
|
||||
"https://www.komikid.com" -> """{"name":"Komikid","base_url":"https://www.komikid.com","supports_latest":true,"item_url":"https://www.komikid.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Fantasy"},{"id":"7","name":"Gender Bender"},{"id":"8","name":"Historical"},{"id":"9","name":"Horror"},{"id":"10","name":"Josei"},{"id":"11","name":"Martial Arts"},{"id":"12","name":"Mature"},{"id":"13","name":"Mecha"},{"id":"14","name":"Mystery"},{"id":"15","name":"One Shot"},{"id":"16","name":"Psychological"},{"id":"17","name":"Romance"},{"id":"18","name":"School Life"},{"id":"19","name":"Sci-fi"},{"id":"20","name":"Seinen"},{"id":"21","name":"Shoujo"},{"id":"22","name":"Shoujo Ai"},{"id":"23","name":"Shounen"},{"id":"24","name":"Shounen Ai"},{"id":"25","name":"Slice of Life"},{"id":"26","name":"Sports"},{"id":"27","name":"Supernatural"},{"id":"28","name":"Tragedy"},{"id":"29","name":"Yaoi"},{"id":"30","name":"Yuri"}]}"""
|
||||
"http://azbivo.webd.pro" -> """{"name":"Nikushima","base_url":"http://azbivo.webd.pro","supports_latest":false,"item_url":"\u003chtml\u003e \n \u003chead\u003e \n \u003cmeta http-equiv\u003d\"Content-Language\" content\u003d\"pl\"\u003e \n \u003cmeta http-equiv name\u003d\"pragma\" content\u003d\"no-cache\"\u003e \n \u003clink href\u003d\"style/style.css\" rel\u003d\"stylesheet\" type\u003d\"text/css\"\u003e \n \u003cmeta http-equiv\u003d\"Refresh\" content\u003d\"0; url\u003dhttps://www.webd.pl/_errnda.php?utm_source\u003dwn07\u0026amp;utm_medium\u003dwww\u0026amp;utm_campaign\u003dblock\"\u003e \n \u003cmeta name\u003d\"Robots\" content\u003d\"index, follow\"\u003e \n \u003cmeta name\u003d\"revisit-after\" content\u003d\"2 days\"\u003e \n \u003cmeta name\u003d\"rating\" content\u003d\"general\"\u003e \n \u003cmeta name\u003d\"keywords\" content\u003d\"STRONA ZAWIESZONA, WEBD, DOMENY, DOMENA, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, NET, .COM, .ORG, TANIE, PHP+MySQL, DOMENY, DOMENA, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, DOMENY, DOMENA, NET, .COM, .ORG, TANIE, PHP+MySQL, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, NET, .COM, .ORG, TANIE, PHP+MySQL\"\u003e \n \u003cmeta name\u003d\"description\" content\u003d\"STRONA ZAWIESZONA - Oferujemy profesjonalny hosting z PHP + MySQL, rejestrujemy domeny. Sprawdz nasz hosting i przetestuj nasze serwery. Kupuj tanio domeny i serwery!\"\u003e \n \u003ctitle\u003eSTRONA ZAWIESZONA - WEBD.PL - Tw<54>j profesjonalny hosting za jedyne 4.99PLN! Serwery z PHP+MySQL, tanie domeny, serwer + domena .pl - taniej sie nie da!\u003c/title\u003e \n \u003cscript type\u003d\"text/javascript\"\u003e\nfunction init() {\n if (!document.getElementById) return\n var imgOriginSrc;\n var imgTemp \u003d new Array();\n var imgarr \u003d document.getElementsByTagName(\u0027img\u0027);\n for (var i \u003d 0; i \u003c imgarr.length; i++) {\n if (imgarr[i].getAttribute(\u0027hsrc\u0027)) {\n imgTemp[i] \u003d new Image();\n imgTemp[i].src \u003d imgarr[i].getAttribute(\u0027hsrc\u0027);\n imgarr[i].onmouseover \u003d function() {\n imgOriginSrc \u003d this.getAttribute(\u0027src\u0027);\n this.setAttribute(\u0027src\u0027,this.getAttribute(\u0027hsrc\u0027))\n }\n imgarr[i].onmouseout \u003d function() {\n this.setAttribute(\u0027src\u0027,imgOriginSrc)\n }\n }\n }\n}\nonload\u003dinit;\n\u003c/script\u003e \n \u003c/head\u003e \n \u003cbody\u003e\n Trwa przekierowanie .... \u0026gt;\u0026gt;\u0026gt;\u0026gt; \u003c!--\n--\u003e \n \u003c/body\u003e\n\u003c/html\u003e/","categories":[]}"""
|
||||
"https://mangadoor.com" -> """{"name":"Mangadoor","base_url":"https://mangadoor.com","supports_latest":true,"item_url":"https://mangadoor.com/manga/","categories":[{"id":"1","name":"Acción"},{"id":"2","name":"Aventura"},{"id":"3","name":"Comedia"},{"id":"4","name":"Drama"},{"id":"5","name":"Ecchi"},{"id":"6","name":"Fantasía"},{"id":"7","name":"Gender Bender"},{"id":"8","name":"Harem"},{"id":"9","name":"Histórico"},{"id":"10","name":"Horror"},{"id":"11","name":"Josei"},{"id":"12","name":"Artes Marciales"},{"id":"13","name":"Maduro"},{"id":"14","name":"Mecha"},{"id":"15","name":"Misterio"},{"id":"16","name":"One Shot"},{"id":"17","name":"Psicológico"},{"id":"18","name":"Romance"},{"id":"19","name":"Escolar"},{"id":"20","name":"Ciencia Ficción"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shoujo"},{"id":"23","name":"Shoujo Ai"},{"id":"24","name":"Shounen"},{"id":"25","name":"Shounen Ai"},{"id":"26","name":"Recuentos de la vida"},{"id":"27","name":"Deportes"},{"id":"28","name":"Supernatural"},{"id":"29","name":"Tragedia"},{"id":"30","name":"Yaoi"},{"id":"31","name":"Yuri"},{"id":"32","name":"Demonios"},{"id":"33","name":"Juegos"},{"id":"34","name":"Policial"},{"id":"35","name":"Militar"},{"id":"36","name":"Thriller"},{"id":"37","name":"Autos"},{"id":"38","name":"Música"},{"id":"39","name":"Vampiros"},{"id":"40","name":"Magia"},{"id":"41","name":"Samurai"},{"id":"42","name":"Boys love"},{"id":"43","name":"Hentai"}]}"""
|
||||
"https://mangas.in" -> """{"name":"Mangas.pw","base_url":"https://mangas.in","supports_latest":true,"item_url":"https://mangas.in/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"},{"id":"33","name":"Hentai"},{"id":"34","name":"Smut"}]}"""
|
||||
"https://manga.utsukushii-bg.com" -> """{"name":"Utsukushii","base_url":"https://manga.utsukushii-bg.com","supports_latest":true,"item_url":"https://manga.utsukushii-bg.com/manga/","categories":[{"id":"1","name":"Екшън"},{"id":"2","name":"Приключенски"},{"id":"3","name":"Комедия"},{"id":"4","name":"Драма"},{"id":"5","name":"Фентъзи"},{"id":"6","name":"Исторически"},{"id":"7","name":"Ужаси"},{"id":"8","name":"Джосей"},{"id":"9","name":"Бойни изкуства"},{"id":"10","name":"Меха"},{"id":"11","name":"Мистерия"},{"id":"12","name":"Самостоятелна/Пилотна глава"},{"id":"13","name":"Психологически"},{"id":"14","name":"Романтика"},{"id":"15","name":"Училищни"},{"id":"16","name":"Научна фантастика"},{"id":"17","name":"Сейнен"},{"id":"18","name":"Шоджо"},{"id":"19","name":"Реализъм"},{"id":"20","name":"Спорт"},{"id":"21","name":"Свръхестествено"},{"id":"22","name":"Трагедия"},{"id":"23","name":"Йокаи"},{"id":"24","name":"Паралелна вселена"},{"id":"25","name":"Супер сили"},{"id":"26","name":"Пародия"},{"id":"27","name":"Шонен"}]}"""
|
||||
"https://phoenix-scans.pl" -> """{"name":"Phoenix-Scans","base_url":"https://phoenix-scans.pl","supports_latest":true,"item_url":"https://phoenix-scans.pl/manga/","categories":[{"id":"1","name":"Shounen"},{"id":"2","name":"Tragedia"},{"id":"3","name":"Szkolne życie"},{"id":"4","name":"Romans"},{"id":"5","name":"Zagadka"},{"id":"6","name":"Horror"},{"id":"7","name":"Dojrzałe"},{"id":"8","name":"Psychologiczne"},{"id":"9","name":"Przygodowe"},{"id":"10","name":"Akcja"},{"id":"11","name":"Komedia"},{"id":"12","name":"Zboczone"},{"id":"13","name":"Fantasy"},{"id":"14","name":"Harem"},{"id":"15","name":"Historyczne"},{"id":"16","name":"Manhua"},{"id":"17","name":"Manhwa"},{"id":"18","name":"Sztuki walki"},{"id":"19","name":"One shot"},{"id":"20","name":"Sci fi"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shounen ai"},{"id":"23","name":"Spokojne życie"},{"id":"24","name":"Sport"},{"id":"25","name":"Nadprzyrodzone"},{"id":"26","name":"Webtoons"},{"id":"27","name":"Dramat"},{"id":"28","name":"Hentai"},{"id":"29","name":"Mecha"},{"id":"30","name":"Gender Bender"},{"id":"31","name":"Gry"},{"id":"32","name":"Yaoi"}],"tags":[{"id":"aktywne","name":"aktywne"},{"id":"zakonczone","name":"zakończone"},{"id":"porzucone","name":"porzucone"},{"id":"zawieszone","name":"zawieszone"},{"id":"zlicencjonowane","name":"zlicencjonowane"},{"id":"hentai","name":"Hentai"}]}"""
|
||||
"https://lelscanvf.cc" -> """{"name":"Lelscan-VF","base_url":"https://lelscanvf.cc","supports_latest":true,"item_url":"https://lelscanvf.cc/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}"""
|
||||
"https://animaregia.net" -> """{"name":"AnimaRegia","base_url":"https://animaregia.net","supports_latest":true,"item_url":"http://animaregia.net/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}"""
|
||||
"https://mangaid.click" -> """{"name":"MangaID","base_url":"https://mangaid.click","supports_latest":true,"item_url":"https://mangaid.click/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"Psychological"},{"id":"18","name":"Romance"},{"id":"19","name":"School Life"},{"id":"20","name":"Sci-fi"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shoujo"},{"id":"23","name":"Shoujo Ai"},{"id":"24","name":"Shounen"},{"id":"25","name":"Shounen Ai"},{"id":"26","name":"Slice of Life"},{"id":"27","name":"Sports"},{"id":"28","name":"Supernatural"},{"id":"29","name":"Tragedy"},{"id":"30","name":"Yaoi"},{"id":"31","name":"Yuri"},{"id":"32","name":"School"},{"id":"33","name":"Isekai"},{"id":"34","name":"Military"}]}"""
|
||||
"https://jpmangas.xyz" -> """{"name":"Jpmangas","base_url":"https://jpmangas.xyz","supports_latest":true,"item_url":"https://jpmangas.xyz/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}"""
|
||||
"https://www.hentaishark.com" -> """{"name":"HentaiShark","base_url":"https://www.hentaishark.com","supports_latest":true,"item_url":"https://www.hentaishark.com/manga/","categories":[{"id":"1","name":"Doujinshi"},{"id":"2","name":"Manga"},{"id":"3","name":"Western"},{"id":"4","name":"non-h"},{"id":"5","name":"imageset"},{"id":"6","name":"artistcg"},{"id":"7","name":"misc"}]}"""
|
||||
"https://amascan.com" -> """{"name":"Ama Scans","base_url":"https://amascan.com","supports_latest":true,"item_url":"https://amascan.com/manga/","categories":[{"id":"1","name":"Ação"},{"id":"2","name":"Aventura"},{"id":"3","name":"Comédia"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasia"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harém"},{"id":"10","name":"Histórico"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Artes Marciais"},{"id":"14","name":"Adulto"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mistério"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psicológico"},{"id":"19","name":"Romance"},{"id":"20","name":"Vida Escolar"},{"id":"21","name":"Ficcção Científica"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shounen"},{"id":"25","name":"Slice of Life"},{"id":"26","name":"Esportes"},{"id":"27","name":"Sobrenatural"},{"id":"28","name":"Tragédia"},{"id":"29","name":"Hentai"},{"id":"30","name":"Terror"},{"id":"31","name":"LGBTQQICAPF2K+"},{"id":"32","name":"Ação"},{"id":"33","name":"Aventura"},{"id":"34","name":"Comédia"},{"id":"35","name":"Doujinshi"},{"id":"36","name":"Drama"},{"id":"37","name":"Ecchi"},{"id":"38","name":"Fantasia"},{"id":"39","name":"Gender Bender"},{"id":"40","name":"Harém"},{"id":"41","name":"Histórico"},{"id":"42","name":"Horror"},{"id":"43","name":"Josei"},{"id":"44","name":"Artes Marciais"},{"id":"45","name":"Adulto"},{"id":"46","name":"Mecha"},{"id":"47","name":"Mistério"},{"id":"48","name":"One Shot"},{"id":"49","name":"Psicológico"},{"id":"50","name":"Romance"},{"id":"51","name":"Vida Escolar"},{"id":"52","name":"Ficcção Científica"},{"id":"53","name":"Seinen"},{"id":"54","name":"Shoujo"},{"id":"55","name":"Shounen"},{"id":"56","name":"Slice of Life"},{"id":"57","name":"Esportes"},{"id":"58","name":"Sobrenatural"},{"id":"59","name":"Tragédia"},{"id":"60","name":"Hentai"},{"id":"61","name":"Terror"},{"id":"62","name":"LGBTQQICAPF2K+"}]}"""
|
||||
"https://manga-fr.cc" -> """{"name":"Manga-FR","base_url":"https://manga-fr.cc","supports_latest":true,"item_url":"https://manga-fr.cc/lecture-en-ligne/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasie"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historique"},{"id":"11","name":"Horreur"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragédie"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"},{"id":"33","name":"Fantastique"},{"id":"34","name":"Webtoon"},{"id":"35","name":"Manhwa"},{"id":"36","name":"Amour"},{"id":"37","name":"Combats"},{"id":"38","name":"Amitié"},{"id":"39","name":"Psychologique"},{"id":"40","name":"Magie"}]}"""
|
||||
"https://mangascan.cc" -> """{"name":"Manga-Scan","base_url":"https://mangascan.cc","supports_latest":true,"item_url":"https://mangascan.cc/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Webtoon"},{"id":"9","name":"Harem"},{"id":"10","name":"Historique"},{"id":"11","name":"Horreur"},{"id":"12","name":"Thriller"},{"id":"13","name":"Arts Martiaux"},{"id":"14","name":"Mature"},{"id":"15","name":"Tragique"},{"id":"16","name":"Mystère"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychologique"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Science-fiction"},{"id":"22","name":"Seinen"},{"id":"23","name":"Erotique"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sport"},{"id":"29","name":"Surnaturel"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Gangster"},{"id":"32","name":"Crime"},{"id":"33","name":"Biographique"},{"id":"34","name":"Fantastique"}]}"""
|
||||
"https://bentoscan.com" -> """{"name":"Bentoscan","base_url":"https://bentoscan.com","supports_latest":true,"item_url":"https://bentoscan.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Crime"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Fantastique"},{"id":"9","name":"Harem"},{"id":"10","name":"Gangster"},{"id":"11","name":"Erotique"},{"id":"12","name":"Historique"},{"id":"13","name":"Arts Martiaux"},{"id":"14","name":"Mature"},{"id":"15","name":"Horreur"},{"id":"16","name":"Mystère"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychologique"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Science-fiction"},{"id":"22","name":"Seinen"},{"id":"23","name":"Suspense"},{"id":"24","name":"Biographique"},{"id":"25","name":"Social"},{"id":"26","name":"Tranche-de-vie"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sport"},{"id":"29","name":"Surnaturel"},{"id":"30","name":"Thriller"},{"id":"31","name":"Tragique"},{"id":"32","name":"Webtoon"}]}"""
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package eu.kanade.tachiyomi.multisrc.monochrome
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.DecimalFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal const val UUID_QUERY = "uuid:"
|
||||
|
||||
private const val ISO_DATE = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
private val dateFormat = SimpleDateFormat(ISO_DATE, Locale.ROOT)
|
||||
|
||||
private val decimalFormat = DecimalFormat("#.##")
|
||||
|
||||
@Serializable
|
||||
data class Results(
|
||||
private val offset: Int,
|
||||
private val limit: Int,
|
||||
private val results: List<Manga>,
|
||||
private val total: Int,
|
||||
) : Iterable<Manga> by results {
|
||||
val hasNext: Boolean
|
||||
get() = total > results.size + offset * limit
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val author: String,
|
||||
val artist: String,
|
||||
val status: String,
|
||||
val id: String,
|
||||
private val version: Int,
|
||||
) {
|
||||
val cover: String
|
||||
get() = "/media/$id/cover.jpg?version=$version"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
private val name: String,
|
||||
private val volume: Int?,
|
||||
val number: Float,
|
||||
val scanGroup: String,
|
||||
private val id: String,
|
||||
private val version: Int,
|
||||
private val length: Int,
|
||||
private val uploadTime: String,
|
||||
) {
|
||||
val title: String
|
||||
get() = buildString {
|
||||
if (volume != null) append("Vol ").append(volume).append(" ")
|
||||
append("Chapter ").append(decimalFormat.format(number))
|
||||
if (name.isNotEmpty()) append(" - ").append(name)
|
||||
}
|
||||
|
||||
val timestamp: Long
|
||||
get() = dateFormat.parse(uploadTime)?.time ?: 0L
|
||||
|
||||
val parts: String
|
||||
get() = "/$id|$version|$length"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user