Update MadTheme, migrate ManhuaScan to MadTheme (#3072)
* MadTheme: general cleanup * MadTheme: add support for both site formats * Remove ManhuaScan * Add KaliScan.com, KaliScan.io, MGJinx * MadTheme: bump base version * Add KaliScan.me * Only set genreKey once
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 13
|
||||
baseVersionCode = 14
|
||||
|
@ -21,6 +21,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
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
|
||||
@ -29,24 +30,25 @@ import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
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),
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 1)
|
||||
.rateLimit(1, 1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// TODO: better cookie sharing
|
||||
// TODO: don't count cached responses against rate limit
|
||||
private val chapterClient: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 12)
|
||||
.rateLimit(1, 12, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
@ -55,6 +57,8 @@ abstract class MadTheme(
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private var genreKey = "genre[]"
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
searchMangaRequest(page, "", FilterList(OrderFilter(0)))
|
||||
@ -100,7 +104,7 @@ abstract class MadTheme(
|
||||
.filter { it.state }
|
||||
.let { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) }
|
||||
list.forEach { genre -> url.addQueryParameter(genreKey, genre.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -120,11 +124,11 @@ abstract class MadTheme(
|
||||
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")
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href"))
|
||||
title = element.selectFirst("a")!!.attr("title")
|
||||
element.selectFirst(".summary")?.text()?.let { description = it }
|
||||
element.select(".genres > *").joinToString { it.text() }.takeIf { it.isNotEmpty() }?.let { genre = it }
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src")
|
||||
}
|
||||
|
||||
/*
|
||||
@ -135,23 +139,25 @@ abstract class MadTheme(
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
title = document.select(".detail h1").first()!!.text()
|
||||
title = document.selectFirst(".detail h1")!!.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")
|
||||
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src")
|
||||
|
||||
val altNames = document.select(".detail h2").first()?.text()
|
||||
val altNames = document.selectFirst(".detail h2")?.text()
|
||||
?.split(',', ';')
|
||||
?.mapNotNull { it.trim().takeIf { it != title } }
|
||||
?: listOf()
|
||||
|
||||
description = document.select(".summary .content").first()?.text() +
|
||||
description = document.select(".summary .content, .summary .content ~ p").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)) {
|
||||
val statusText = document.selectFirst(".detail .meta > p > strong:contains(Status) ~ a")!!.text()
|
||||
status = when (statusText.lowercase(Locale.ENGLISH)) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
"on-hold" -> SManga.ON_HIATUS
|
||||
"canceled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
@ -187,7 +193,14 @@ abstract class MadTheme(
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request =
|
||||
GET("$baseUrl/api/manga${manga.url}/chapters?source=detail", headers)
|
||||
MANGA_ID_REGEX.find(manga.url)?.groupValues?.get(1)?.let { mangaId ->
|
||||
val url = "$baseUrl/service/backend/chaplist/".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("manga_id", mangaId)
|
||||
.addQueryParameter("manga_name", manga.title)
|
||||
.build()
|
||||
|
||||
GET(url, headers)
|
||||
} ?: GET("$baseUrl/api/manga${manga.url}/chapters?source=detail", headers)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (genresList == null) {
|
||||
@ -204,16 +217,25 @@ abstract class MadTheme(
|
||||
.absUrl("href")
|
||||
.removePrefix(baseUrl)
|
||||
|
||||
name = element.select(".chapter-title").first()!!.text()
|
||||
date_upload = parseChapterDate(element.select(".chapter-update").first()?.text())
|
||||
name = element.selectFirst(".chapter-title")!!.text()
|
||||
date_upload = parseChapterDate(element.selectFirst(".chapter-update")?.text())
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val html = document.html()
|
||||
val mangaId = MANGA_ID_REGEX.find(document.location())?.groupValues?.get(1)
|
||||
val chapterId = CHAPTER_ID_REGEX.find(document.html())?.groupValues?.get(1)
|
||||
|
||||
val html = if (mangaId != null && chapterId != null) {
|
||||
val url = GET("$baseUrl/service/backend/chapterServer/?server_id=1&chapter_id=$chapterId", headers)
|
||||
client.newCall(url).execute().body.string()
|
||||
} else {
|
||||
document.html()
|
||||
}
|
||||
val realDocument = Jsoup.parse(html, document.location())
|
||||
|
||||
if (!html.contains("var mainServer = \"")) {
|
||||
val chapterImagesFromHtml = document.select("#chapter-images img")
|
||||
val chapterImagesFromHtml = realDocument.select("#chapter-images img, .chapter-image[data-src]")
|
||||
|
||||
// 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
|
||||
@ -292,7 +314,7 @@ abstract class MadTheme(
|
||||
}
|
||||
|
||||
return when {
|
||||
"ago".endsWith(date) -> {
|
||||
" ago" in date -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
else -> dateFormat.tryParse(date)
|
||||
@ -300,10 +322,12 @@ abstract class MadTheme(
|
||||
}
|
||||
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val number = NUMBER_REGEX.find(date)?.groupValues?.getOrNull(0)?.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("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
|
||||
@ -314,13 +338,21 @@ abstract class MadTheme(
|
||||
|
||||
// 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`())
|
||||
return document.selectFirst(".checkbox-group.genres")?.select(".checkbox-wrapper")?.run {
|
||||
firstOrNull()?.selectFirst("input")?.attr("name")?.takeIf { it.isNotEmpty() }?.let { genreKey = it }
|
||||
map {
|
||||
Genre(it.selectFirst(".radio__label")!!.text(), it.selectFirst("input")!!.`val`())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
override fun getFilterList() = FilterList(
|
||||
// TODO: Filters for sites that support it:
|
||||
// excluded genres
|
||||
// genre inclusion mode
|
||||
// bookmarks
|
||||
// author
|
||||
GenreFilter(getGenreList()),
|
||||
StatusFilter(),
|
||||
OrderFilter(),
|
||||
@ -352,6 +384,7 @@ abstract class MadTheme(
|
||||
Pair("Updated", "updated_at"),
|
||||
Pair("Created", "created_at"),
|
||||
Pair("Name A-Z", "name"),
|
||||
// Pair("Number of Chapters", "total_chapters"),
|
||||
Pair("Rating", "rating"),
|
||||
),
|
||||
state,
|
||||
@ -365,4 +398,10 @@ abstract class MadTheme(
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MANGA_ID_REGEX = """/manga/(\d+)-""".toRegex()
|
||||
private val CHAPTER_ID_REGEX = """chapterId\s*=\s*(\d+)""".toRegex()
|
||||
private val NUMBER_REGEX = """\d+""".toRegex()
|
||||
}
|
||||
}
|
||||
|
10
src/en/kaliscancom/build.gradle
Normal file
@ -0,0 +1,10 @@
|
||||
ext {
|
||||
extName = 'KaliScan.com'
|
||||
extClass = '.KaliScanCom'
|
||||
themePkg = 'madtheme'
|
||||
baseUrl = 'https://kaliscan.com'
|
||||
overrideVersionCode = 0
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/kaliscancom/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/en/kaliscancom/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/en/kaliscancom/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/en/kaliscancom/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/en/kaliscancom/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 41 KiB |
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.extension.en.kaliscancom
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
|
||||
|
||||
class KaliScanCom : MadTheme("KaliScan.com", "https://kaliscan.com", "en")
|
10
src/en/kaliscanio/build.gradle
Normal file
@ -0,0 +1,10 @@
|
||||
ext {
|
||||
extName = 'KaliScan.io'
|
||||
extClass = '.KaliScanIo'
|
||||
themePkg = 'madtheme'
|
||||
baseUrl = 'https://kaliscan.io'
|
||||
overrideVersionCode = 0
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/kaliscanio/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/en/kaliscanio/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/en/kaliscanio/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/en/kaliscanio/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/en/kaliscanio/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 41 KiB |
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.extension.en.kaliscanio
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
|
||||
|
||||
class KaliScanIo : MadTheme("KaliScan.io", "https://kaliscan.io", "en")
|
10
src/en/kaliscanme/build.gradle
Normal file
@ -0,0 +1,10 @@
|
||||
ext {
|
||||
extName = 'KaliScan.me'
|
||||
extClass = '.KaliScanMe'
|
||||
themePkg = 'madtheme'
|
||||
baseUrl = 'https://kaliscan.me'
|
||||
overrideVersionCode = 0
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/kaliscanme/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/en/kaliscanme/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/en/kaliscanme/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/en/kaliscanme/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/en/kaliscanme/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 41 KiB |
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.extension.en.kaliscanme
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
|
||||
|
||||
class KaliScanMe : MadTheme("KaliScan.me", "https://kaliscan.me", "en")
|
@ -1,8 +0,0 @@
|
||||
ext {
|
||||
extName = 'ManhuaScan'
|
||||
extClass = '.ManhuaScan'
|
||||
extVersionCode = 8
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 15 KiB |
@ -1,333 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhuascan
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
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.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.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.Calendar
|
||||
|
||||
class ManhuaScan : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val name = "ManhuaScan"
|
||||
|
||||
private val preferences: SharedPreferences =
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
|
||||
override val baseUrl = getMirror()
|
||||
|
||||
override val client by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/popular${page.getPage()}", headers)
|
||||
|
||||
override fun popularMangaSelector(): String = ".manga-list > .book-item"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
thumbnail_url = element.selectFirst(".thumb img")?.imgAttr()
|
||||
element.selectFirst(".title a")!!.run {
|
||||
title = text()
|
||||
setUrlWithoutDomain(attr("abs:href"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String = ".paginator > .active + a"
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
GET("$baseUrl/latest${page.getPage()}", headers)
|
||||
|
||||
override fun latestUpdatesSelector(): String =
|
||||
popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||
popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String =
|
||||
popularMangaNextPageSelector()
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val genre = filters.firstInstanceOrNull<GenreFilter>()
|
||||
val genreInclusion = filters.firstInstanceOrNull<GenreInclusionFilter>()
|
||||
val status = filters.firstInstanceOrNull<StatusFilter>()
|
||||
val orderBy = filters.firstInstanceOrNull<OrderByFilter>()
|
||||
val author = filters.firstInstanceOrNull<AuthorFilter>()
|
||||
|
||||
val url = "$baseUrl/search${page.getPage()}".toHttpUrl().newBuilder().apply {
|
||||
genre?.included?.forEach {
|
||||
addEncodedQueryParameter("include[]", it)
|
||||
}
|
||||
genre?.excluded?.forEach {
|
||||
addEncodedQueryParameter("exclude[]", it)
|
||||
}
|
||||
addQueryParameter("include_mode", genreInclusion?.toUriPart())
|
||||
addQueryParameter("bookmark", "off")
|
||||
addQueryParameter("status", status?.toUriPart())
|
||||
addQueryParameter("sort", orderBy?.toUriPart())
|
||||
if (query.isNotEmpty()) {
|
||||
addQueryParameter("q", query)
|
||||
}
|
||||
if (author?.state?.isNotEmpty() == true) {
|
||||
addQueryParameter("author", author.state)
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga =
|
||||
popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String =
|
||||
popularMangaNextPageSelector()
|
||||
|
||||
// =============================== Filters ==============================
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
GenreFilter(),
|
||||
GenreInclusionFilter(),
|
||||
Filter.Separator(),
|
||||
StatusFilter(),
|
||||
OrderByFilter(),
|
||||
AuthorFilter(),
|
||||
)
|
||||
|
||||
// =========================== Manga Details ============================
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
var alternativeName = ""
|
||||
|
||||
document.selectFirst(".book-info")?.run {
|
||||
genre = select(".meta p:has(strong:contains(Genres)) a").joinToString(", ") { it.text().removeSuffix(" ,") }
|
||||
author = select(".meta p:has(strong:contains(Authors)) a").joinToString(", ") { it.text() }
|
||||
thumbnail_url = selectFirst("#cover img")?.imgAttr()
|
||||
status = selectFirst(".meta p:has(strong:contains(Status)) a").parseStatus()
|
||||
title = selectFirst("h1")!!.text()
|
||||
selectFirst("h2")?.also {
|
||||
alternativeName = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
description = buildString {
|
||||
document.selectFirst(".summary > p:not([style]):not(:empty)")?.let {
|
||||
append(it.text())
|
||||
if (alternativeName.isNotEmpty()) {
|
||||
append("\n\n")
|
||||
}
|
||||
}
|
||||
if (alternativeName.isNotEmpty()) {
|
||||
append("Alternative name(s): $alternativeName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element?.parseStatus(): Int = with(this?.text()) {
|
||||
return when {
|
||||
equals("ongoing", true) -> SManga.ONGOING
|
||||
equals("completed", true) -> SManga.COMPLETED
|
||||
equals("on-hold", true) -> SManga.ON_HIATUS
|
||||
equals("canceled", true) -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Chapters ==============================
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = manga.url.substringAfter("manga/").substringBefore("-")
|
||||
|
||||
val chapterHeaders = headersBuilder().apply {
|
||||
add("Accept", "*/*")
|
||||
add("Host", baseUrl.toHttpUrl().host)
|
||||
set("Referer", baseUrl + manga.url)
|
||||
}.build()
|
||||
|
||||
val url = "$baseUrl/service/backend/chaplist/?manga_id=$id&manga_name=${manga.title}"
|
||||
|
||||
return GET(url, chapterHeaders)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "ul > li"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
element.selectFirst("time")?.also {
|
||||
date_upload = it.text().parseRelativeDate()
|
||||
}
|
||||
name = element.selectFirst("strong")!!.text()
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href"))
|
||||
}
|
||||
|
||||
// From OppaiStream
|
||||
private fun String.parseRelativeDate(): 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
|
||||
val relativeDate = this.split(" ").firstOrNull()
|
||||
?.replace("one", "1")
|
||||
?.replace("a", "1")
|
||||
?.toIntOrNull()
|
||||
?: return 0L
|
||||
|
||||
when {
|
||||
// parse: 30 seconds ago
|
||||
"second" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.SECOND, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parses: "42 minutes ago"
|
||||
"minute" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.MINUTE, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parses: "1 hour ago" and "2 hours ago"
|
||||
"hour" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.HOUR, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parses: "2 days ago"
|
||||
"day" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parses: "2 weeks ago"
|
||||
"week" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parses: "2 months ago"
|
||||
"month" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.MONTH, -relativeDate) }.timeInMillis
|
||||
}
|
||||
// parse: "2 years ago"
|
||||
"year" in this -> {
|
||||
parsedDate = now.apply { add(Calendar.YEAR, -relativeDate) }.timeInMillis
|
||||
}
|
||||
}
|
||||
return parsedDate
|
||||
}
|
||||
|
||||
// =============================== Pages ================================
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val scriptData = document.selectFirst("script:containsData(chapterId)")?.data()
|
||||
?: throw Exception("Unable to find script data")
|
||||
val chapterId = CHAPTER_ID_REGEX.find(scriptData)?.groupValues?.get(1)
|
||||
?: throw Exception("Unable to retrieve chapterId")
|
||||
|
||||
val pagesHeaders = headersBuilder().apply {
|
||||
add("Accept", "*/*")
|
||||
add("Host", baseUrl.toHttpUrl().host)
|
||||
set("Referer", document.location())
|
||||
}.build()
|
||||
val pagesUrl = "$baseUrl/service/backend/chapterServer/?server_id=$server&chapter_id=$chapterId"
|
||||
|
||||
val pagesDocument = client.newCall(
|
||||
GET(pagesUrl, pagesHeaders),
|
||||
).execute().asJsoup()
|
||||
|
||||
return pagesDocument.select("div").map { page ->
|
||||
val url = page.imgAttr()
|
||||
val index = page.id().substringAfterLast("-").toInt()
|
||||
Page(index, document.location(), url)
|
||||
}.sortedBy { it.index }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imgHeaders = headersBuilder().apply {
|
||||
add("Accept", "*/*")
|
||||
add("Host", page.imageUrl!!.toHttpUrl().host)
|
||||
set("Referer", page.url)
|
||||
}.build()
|
||||
|
||||
return GET(page.imageUrl!!, imgHeaders)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
|
||||
private fun Int.getPage(): String = if (this == 1) "" else "?page=$this"
|
||||
|
||||
private 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")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CHAPTER_ID_REGEX = Regex("""chapterId\s*=\s*(\d+)""")
|
||||
|
||||
private const val MIRROR_PREF_KEY = "pref_mirror"
|
||||
private const val MIRROR_PREF_TITLE = "Select Mirror (Requires Restart)"
|
||||
private val MIRROR_PREF_ENTRIES = arrayOf("manhuascan.com", "manhuascan.io", "mangajinx.com")
|
||||
private val MIRROR_PREF_ENTRY_VALUES = MIRROR_PREF_ENTRIES.map { "https://$it" }.toTypedArray()
|
||||
private val MIRROR_PREF_DEFAULT_VALUE = MIRROR_PREF_ENTRY_VALUES.first()
|
||||
|
||||
private const val SERVER_PREF_KEY = "pref_server"
|
||||
private const val SERVER_PREF_TITLE = "Image Server"
|
||||
private val SERVER_PREF_ENTRIES = arrayOf("Server 1", "Server 2")
|
||||
private val SERVER_PREF_ENTRY_VALUES = SERVER_PREF_ENTRIES.map { it.substringAfter(" ") }.toTypedArray()
|
||||
private val SERVER_PREF_DEFAULT_VALUE = SERVER_PREF_ENTRY_VALUES.first()
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = MIRROR_PREF_KEY
|
||||
title = MIRROR_PREF_TITLE
|
||||
entries = MIRROR_PREF_ENTRIES
|
||||
entryValues = MIRROR_PREF_ENTRY_VALUES
|
||||
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = SERVER_PREF_KEY
|
||||
title = SERVER_PREF_TITLE
|
||||
entries = SERVER_PREF_ENTRIES
|
||||
entryValues = SERVER_PREF_ENTRY_VALUES
|
||||
setDefaultValue(SERVER_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private fun getMirror(): String =
|
||||
preferences.getString(MIRROR_PREF_KEY, MIRROR_PREF_DEFAULT_VALUE)!!
|
||||
|
||||
private val server
|
||||
get() = preferences.getString(SERVER_PREF_KEY, SERVER_PREF_DEFAULT_VALUE)!!
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhuascan
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
inline fun <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
class Genre(val id: String, name: String) : Filter.TriState(name)
|
||||
|
||||
class GenreFilter : Filter.Group<Genre>(
|
||||
"Genres",
|
||||
listOf(
|
||||
Genre("action", "Action"),
|
||||
Genre("adaptation", "Adaptation"),
|
||||
Genre("adult", "Adult"),
|
||||
Genre("adventure", "Adventure"),
|
||||
Genre("animal", "Animal"),
|
||||
Genre("anthology", "Anthology"),
|
||||
Genre("cartoon", "Cartoon"),
|
||||
Genre("comedy", "Comedy"),
|
||||
Genre("comic", "Comic"),
|
||||
Genre("cooking", "Cooking"),
|
||||
Genre("demons", "Demons"),
|
||||
Genre("doujinshi", "Doujinshi"),
|
||||
Genre("drama", "Drama"),
|
||||
Genre("ecchi", "Ecchi"),
|
||||
Genre("fantasy", "Fantasy"),
|
||||
Genre("full-color", "Full Color"),
|
||||
Genre("game", "Game"),
|
||||
Genre("gender-bender", "Gender bender"),
|
||||
Genre("ghosts", "Ghosts"),
|
||||
Genre("harem", "Harem"),
|
||||
Genre("historical", "Historical"),
|
||||
Genre("horror", "Horror"),
|
||||
Genre("isekai", "Isekai"),
|
||||
Genre("josei", "Josei"),
|
||||
Genre("long-strip", "Long strip"),
|
||||
Genre("mafia", "Mafia"),
|
||||
Genre("magic", "Magic"),
|
||||
Genre("manga", "Manga"),
|
||||
Genre("manhua", "Manhua"),
|
||||
Genre("manhwa", "Manhwa"),
|
||||
Genre("martial-arts", "Martial arts"),
|
||||
Genre("mature", "Mature"),
|
||||
Genre("mecha", "Mecha"),
|
||||
Genre("medical", "Medical"),
|
||||
Genre("military", "Military"),
|
||||
Genre("monster", "Monster"),
|
||||
Genre("monster-girls", "Monster girls"),
|
||||
Genre("monsters", "Monsters"),
|
||||
Genre("music", "Music"),
|
||||
Genre("mystery", "Mystery"),
|
||||
Genre("office", "Office"),
|
||||
Genre("office-workers", "Office workers"),
|
||||
Genre("one-shot", "One shot"),
|
||||
Genre("police", "Police"),
|
||||
Genre("psychological", "Psychological"),
|
||||
Genre("reincarnation", "Reincarnation"),
|
||||
Genre("romance", "Romance"),
|
||||
Genre("school-life", "School life"),
|
||||
Genre("sci-fi", "Sci fi"),
|
||||
Genre("science-fiction", "Science fiction"),
|
||||
Genre("seinen", "Seinen"),
|
||||
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("soft-yaoi", "Soft Yaoi"),
|
||||
Genre("sports", "Sports"),
|
||||
Genre("super-power", "Super Power"),
|
||||
Genre("superhero", "Superhero"),
|
||||
Genre("supernatural", "Supernatural"),
|
||||
Genre("thriller", "Thriller"),
|
||||
Genre("time-travel", "Time travel"),
|
||||
Genre("tragedy", "Tragedy"),
|
||||
Genre("vampire", "Vampire"),
|
||||
Genre("vampires", "Vampires"),
|
||||
Genre("video-games", "Video games"),
|
||||
Genre("villainess", "Villainess"),
|
||||
Genre("web-comic", "Web comic"),
|
||||
Genre("webtoons", "Webtoons"),
|
||||
Genre("yaoi", "Yaoi"),
|
||||
Genre("yuri", "Yuri"),
|
||||
Genre("zombies", "Zombies"),
|
||||
),
|
||||
) {
|
||||
val included: List<String>?
|
||||
get() = state.filter { it.isIncluded() }.map { it.id }.takeUnless { it.isEmpty() }
|
||||
|
||||
val excluded: List<String>?
|
||||
get() = state.filter { it.isExcluded() }.map { it.id }.takeUnless { it.isEmpty() }
|
||||
}
|
||||
|
||||
class GenreInclusionFilter : UriPartFilter(
|
||||
"Genre Inclusion Mode",
|
||||
arrayOf(
|
||||
Pair("AND (All Selected Genres)", "and"),
|
||||
Pair("OR (Any Selected Genres)", "or"),
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : UriPartFilter(
|
||||
"Status",
|
||||
arrayOf(
|
||||
Pair("All", "all"),
|
||||
Pair("Ongoing", "ongoing"),
|
||||
Pair("Completed", "completed"),
|
||||
),
|
||||
)
|
||||
|
||||
class OrderByFilter : UriPartFilter(
|
||||
"Order By",
|
||||
arrayOf(
|
||||
Pair("Views", "views"),
|
||||
Pair("Updated", "updated_at"),
|
||||
Pair("Created", "created_at"),
|
||||
Pair("Name A-Z", "name"),
|
||||
Pair("Number of Chapters", "total_chapters"),
|
||||
Pair("Rating", "rating"),
|
||||
),
|
||||
)
|
||||
|
||||
class AuthorFilter : Filter.Text("Author name")
|
10
src/en/mgjinx/build.gradle
Normal file
@ -0,0 +1,10 @@
|
||||
ext {
|
||||
extName = 'MGJinx'
|
||||
extClass = '.MGJinx'
|
||||
themePkg = 'madtheme'
|
||||
baseUrl = 'https://mgjinx.com'
|
||||
overrideVersionCode = 0
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/mgjinx/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/en/mgjinx/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/en/mgjinx/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/en/mgjinx/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/en/mgjinx/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 41 KiB |
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.extension.en.mgjinx
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
|
||||
|
||||
class MGJinx : MadTheme("MGJinx", "https://mgjinx.com", "en")
|