Initial commit

This commit is contained in:
FourTOne5
2024-01-09 04:12:39 +06:00
commit 600c345dfe
8593 changed files with 150590 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]}/"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 dequip"),
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 lentreteniment"),
Tag(61, "Isekai"),
Tag(58, "Joc dalt risc"),
Tag(33, "Joc destratè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())
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "🔒 "
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
# MangaBox
Table of Content
- [FAQ](#FAQ)
[Uncomment this if needed; and replace &#40; and &#41; with ( and )]: <> (- [Guides]&#40;#Guides&#41;)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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