New extension GMANGA (#5274)

* Initial commit of GMANGA

* Enable text search

* Implement search filters

* Fix bug unable to render pages of non-webp chapters

* Replace default user-agent with a more generic one

* Add preference to select picking most viewed scan or just show all

* Implement rate limiting to avoid IP ban

* Translate messages to Arabic

* Split functionality to different files
This commit is contained in:
Clarence Castillo 2020-12-30 19:42:27 +08:00 committed by GitHub
parent de178465c7
commit a552a5267b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 671 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'GMANGA'
pkgNameSuffix = 'ar.gmanga'
extClass = '.Gmanga'
extVersionCode = 1
libVersion = '1.2'
containsNsfw = false
}
dependencies {
implementation project(':lib-ratelimit')
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -0,0 +1,181 @@
package eu.kanade.tachiyomi.extension.ar.gmanga
import android.support.v7.preference.PreferenceScreen
import com.github.salomonbrys.kotson.fromJson
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.nullString
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_CHAPTER_LISTING
import eu.kanade.tachiyomi.extension.ar.gmanga.GmangaPreferences.Companion.PREF_CHAPTER_LISTING_SHOW_POPULAR
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
class Gmanga : ConfigurableSource, HttpSource() {
private val domain: String = "gmanga.me"
override val baseUrl: String = "https://$domain"
override val lang: String = "ar"
override val name: String = "GMANGA"
override val supportsLatest: Boolean = true
private val gson = Gson()
private val preferences = GmangaPreferences(id)
private val rateLimitInterceptor = RateLimitInterceptor(4)
override val client: OkHttpClient = network.client.newBuilder()
.addNetworkInterceptor(rateLimitInterceptor)
.build()
override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", USER_AGENT)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) = preferences.setupPreferenceScreen(screen)
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) = preferences.setupPreferenceScreen(screen)
override fun chapterListRequest(manga: SManga): Request {
val mangaId = manga.url.substringAfterLast("/")
return GET("$baseUrl/api/mangas/$mangaId/releases", headers)
}
@ExperimentalStdlibApi
override fun chapterListParse(response: Response): List<SChapter> {
val data = decryptResponse(response)
val chapters: List<JsonArray> = buildList {
val allChapters = data["rows"][0]["rows"].asJsonArray.map { it.asJsonArray }
when (preferences.getString(PREF_CHAPTER_LISTING)) {
PREF_CHAPTER_LISTING_SHOW_POPULAR -> addAll(
allChapters.groupBy { it.asJsonArray[6].asFloat }
.map { (_: Float, versions: List<JsonArray>) -> versions.maxByOrNull { it[4].asLong }!! }
)
else -> addAll(allChapters)
}
}
return chapters.map {
SChapter.create().apply {
chapter_number = it[6].asFloat
val chapterName = it[8].asString.let { if (it.trim() != "") " - $it" else "" }
url = "/r/${it[0]}"
name = "${chapter_number.let { if (it % 1 > 0) it else it.toInt() }}$chapterName"
date_upload = it[3].asLong * 1000
scanlator = it[10].asString
}
}
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used")
override fun latestUpdatesParse(response: Response): MangasPage {
val data = gson.fromJson<JsonObject>(response.asJsoup().select(".js-react-on-rails-component").html())
return MangasPage(
data["mangaDataAction"]["newMangas"].asJsonArray.map {
SManga.create().apply {
url = "/mangas/${it["id"].asString}"
title = it["title"].asString
val thumbnail = "medium_${it["cover"].asString.substringBeforeLast(".")}.webp"
thumbnail_url = "https://media.$domain/uploads/manga/cover/${it["id"].asString}/$thumbnail"
}
},
false
)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/mangas/latest", headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val data = gson.fromJson<JsonObject>(response.asJsoup().select(".js-react-on-rails-component").html())
val mangaData = data["mangaDataAction"]["mangaData"].asJsonObject
return SManga.create().apply {
description = mangaData["summary"].nullString ?: ""
artist = mangaData["artists"].asJsonArray.joinToString(", ") { it.asJsonObject["name"].asString }
author = mangaData["authors"].asJsonArray.joinToString(", ") { it.asJsonObject["name"].asString }
genre = mangaData["categories"].asJsonArray.joinToString(", ") { it.asJsonObject["name"].asString }
}
}
override fun pageListParse(response: Response): List<Page> {
val url = response.request().url().toString()
val data = gson.fromJson<JsonObject>(response.asJsoup().select(".js-react-on-rails-component").html())
val releaseData = data["readerDataAction"]["readerData"]["release"].asJsonObject
val hasWebP = releaseData["webp_pages"].asJsonArray.size() > 0
return releaseData[if (hasWebP) "webp_pages" else "pages"].asJsonArray.map { it.asString }.mapIndexed { index, pageUri ->
Page(
index,
"$url#page_$index",
"https://media.$domain/uploads/releases/${releaseData["storage_key"].asString}/mq${if (hasWebP) "_webp" else ""}/$pageUri"
)
}
}
override fun popularMangaParse(response: Response) = searchMangaParse(response)
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", getFilterList())
override fun searchMangaParse(response: Response): MangasPage {
val data = decryptResponse(response)
val mangas = data["mangas"].asJsonArray
return MangasPage(
mangas.asJsonArray.map {
SManga.create().apply {
url = "/mangas/${it["id"].asString}"
title = it["title"].asString
val thumbnail = "medium_${it["cover"].asString.substringBeforeLast(".")}.webp"
thumbnail_url = "https://media.$domain/uploads/manga/cover/${it["id"].asString}/$thumbnail"
}
},
mangas.size() == 50
)
}
private fun decryptResponse(response: Response): JsonObject {
val encryptedData = gson.fromJson<JsonObject>(response.body()!!.string())["data"].asString
val decryptedData = decrypt(encryptedData)
return gson.fromJson(decryptedData)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return GmangaFilters.buildSearchPayload(page, query, filters).let {
val body = RequestBody.create(MEDIA_TYPE, it.toString())
POST("$baseUrl/api/mangas/search", headers, body)
}
}
override fun getFilterList() = GmangaFilters.getFilterList()
companion object {
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36"
private val MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8")
}
}

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.extension.ar.gmanga
import android.annotation.TargetApi
import android.os.Build
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
fun decrypt(responseData: String): String {
val enc = responseData.split("|")
val secretKey = enc[3].sha256().hexStringToByteArray()
return enc[0].aesDecrypt(secretKey, enc[2])
}
private fun String.hexStringToByteArray(): ByteArray {
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = (
(Character.digit(this[i], 16) shl 4) +
Character.digit(this[i + 1], 16)
).toByte()
i += 2
}
return data
}
private fun String.sha256(): String {
return MessageDigest
.getInstance("SHA-256")
.digest(this.toByteArray())
.fold("", { str, it -> str + "%02x".format(it) })
}
@TargetApi(Build.VERSION_CODES.O)
private fun String.aesDecrypt(secretKey: ByteArray, ivString: String): String {
val decoder = Base64.getDecoder()
val c = Cipher.getInstance("AES/CBC/PKCS5Padding")
val sk = SecretKeySpec(secretKey, "AES")
val iv = IvParameterSpec(decoder.decode(ivString.toByteArray(Charsets.UTF_8)))
c.init(Cipher.DECRYPT_MODE, sk, iv)
val byteStr = decoder.decode(this.toByteArray(Charsets.UTF_8))
return String(c.doFinal(byteStr))
}

View File

@ -0,0 +1,334 @@
package eu.kanade.tachiyomi.extension.ar.gmanga
import android.annotation.SuppressLint
import com.github.salomonbrys.kotson.addAll
import com.github.salomonbrys.kotson.addProperty
import com.google.gson.JsonArray
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import java.lang.Exception
import java.text.ParseException
import java.text.SimpleDateFormat
class GmangaFilters() {
companion object {
fun getFilterList() = FilterList(
MangaTypeFilter(),
OneShotFilter(),
StoryStatusFilter(),
TranslationStatusFilter(),
ChapterCountFilter(),
DateRangeFilter(),
CategoryFilter()
)
fun buildSearchPayload(page: Int, query: String = "", filters: FilterList): JsonObject {
val mangaTypeFilter = filters.findInstance<MangaTypeFilter>()!!
val oneShotFilter = filters.findInstance<OneShotFilter>()!!
val storyStatusFilter = filters.findInstance<StoryStatusFilter>()!!
val translationStatusFilter = filters.findInstance<TranslationStatusFilter>()!!
val chapterCountFilter = filters.findInstance<ChapterCountFilter>()!!
val dateRangeFilter = filters.findInstance<DateRangeFilter>()!!
val categoryFilter = filters.findInstance<CategoryFilter>()!!
return JsonObject().apply {
oneShotFilter.state.first().let {
when {
it.isIncluded() -> addProperty("oneshot", true)
it.isExcluded() -> addProperty("oneshot", false)
else -> addProperty("oneshot", JsonNull.INSTANCE)
}
}
addProperty("title", query)
addProperty("page", page)
addProperty(
"manga_types",
JsonObject().apply {
addProperty(
"include",
JsonArray().apply {
addAll(mangaTypeFilter.state.filter { it.isIncluded() }.map { it.id })
}
)
addProperty(
"exclude",
JsonArray().apply {
addAll(mangaTypeFilter.state.filter { it.isExcluded() }.map { it.id })
}
)
}
)
addProperty(
"story_status",
JsonObject().apply {
addProperty(
"include",
JsonArray().apply {
addAll(storyStatusFilter.state.filter { it.isIncluded() }.map { it.id })
}
)
addProperty(
"exclude",
JsonArray().apply {
addAll(storyStatusFilter.state.filter { it.isExcluded() }.map { it.id })
}
)
}
)
addProperty(
"translation_status",
JsonObject().apply {
addProperty(
"include",
JsonArray().apply {
addAll(translationStatusFilter.state.filter { it.isIncluded() }.map { it.id })
}
)
addProperty(
"exclude",
JsonArray().apply {
addAll(translationStatusFilter.state.filter { it.isExcluded() }.map { it.id })
}
)
}
)
addProperty(
"categories",
JsonObject().apply {
addProperty(
"include",
JsonArray().apply {
add(JsonNull.INSTANCE) // always included, maybe to avoid shifting index in the backend
addAll(categoryFilter.state.filter { it.isIncluded() }.map { it.id })
}
)
addProperty(
"exclude",
JsonArray().apply {
addAll(categoryFilter.state.filter { it.isExcluded() }.map { it.id })
}
)
}
)
addProperty(
"chapters",
JsonObject().apply {
addPropertyFromValidatingTextFilter(
chapterCountFilter.state.first {
it.id == FILTER_ID_MIN_CHAPTER_COUNT
},
"min",
ERROR_INVALID_MIN_CHAPTER_COUNT,
""
)
addPropertyFromValidatingTextFilter(
chapterCountFilter.state.first {
it.id == FILTER_ID_MAX_CHAPTER_COUNT
},
"max",
ERROR_INVALID_MAX_CHAPTER_COUNT,
""
)
}
)
addProperty(
"dates",
JsonObject().apply {
addPropertyFromValidatingTextFilter(
dateRangeFilter.state.first {
it.id == FILTER_ID_START_DATE
},
"start",
ERROR_INVALID_START_DATE
)
addPropertyFromValidatingTextFilter(
dateRangeFilter.state.first {
it.id == FILTER_ID_END_DATE
},
"end",
ERROR_INVALID_END_DATE
)
}
)
}
}
// filter IDs
private const val FILTER_ID_ONE_SHOT = "oneshot"
private const val FILTER_ID_START_DATE = "start"
private const val FILTER_ID_END_DATE = "end"
private const val FILTER_ID_MIN_CHAPTER_COUNT = "min"
private const val FILTER_ID_MAX_CHAPTER_COUNT = "max"
// error messages
private const val ERROR_INVALID_START_DATE = "تاريخ بداية غير صالح"
private const val ERROR_INVALID_END_DATE = " تاريخ نهاية غير صالح"
private const val ERROR_INVALID_MIN_CHAPTER_COUNT = "الحد الأدنى لعدد الفصول غير صالح"
private const val ERROR_INVALID_MAX_CHAPTER_COUNT = "الحد الأقصى لعدد الفصول غير صالح"
private class MangaTypeFilter() : Filter.Group<TagFilter>(
"الأصل",
listOf(
TagFilter("1", "يابانية", TriState.STATE_INCLUDE),
TagFilter("2", "كورية", TriState.STATE_INCLUDE),
TagFilter("3", "صينية", TriState.STATE_INCLUDE),
TagFilter("4", "عربية", TriState.STATE_INCLUDE),
TagFilter("5", "كوميك", TriState.STATE_INCLUDE),
TagFilter("6", "هواة", TriState.STATE_INCLUDE),
TagFilter("7", "إندونيسية", TriState.STATE_INCLUDE),
TagFilter("8", "روسية", TriState.STATE_INCLUDE),
)
)
private class OneShotFilter() : Filter.Group<TagFilter>(
"ونشوت؟",
listOf(
TagFilter(FILTER_ID_ONE_SHOT, "نعم", TriState.STATE_EXCLUDE)
)
)
private class StoryStatusFilter() : Filter.Group<TagFilter>(
"حالة القصة",
listOf(
TagFilter("2", "مستمرة"),
TagFilter("3", "منتهية")
)
)
private class TranslationStatusFilter() : Filter.Group<TagFilter>(
"حالة الترجمة",
listOf(
TagFilter("0", "منتهية"),
TagFilter("1", "مستمرة"),
TagFilter("2", "متوقفة"),
TagFilter("3", "غير مترجمة", TriState.STATE_EXCLUDE),
)
)
private class ChapterCountFilter() : Filter.Group<IntFilter>(
"عدد الفصول",
listOf(
IntFilter(FILTER_ID_MIN_CHAPTER_COUNT, "على الأقل"),
IntFilter(FILTER_ID_MAX_CHAPTER_COUNT, "على الأكثر")
)
)
private class DateRangeFilter() : Filter.Group<DateFilter>(
"تاريخ النشر",
listOf(
DateFilter(FILTER_ID_START_DATE, "تاريخ النشر"),
DateFilter(FILTER_ID_END_DATE, "تاريخ الإنتهاء")
)
)
private class CategoryFilter() : Filter.Group<TagFilter>(
"التصنيفات",
listOf(
TagFilter("1", "إثارة"),
TagFilter("2", "أكشن"),
TagFilter("3", "الحياة المدرسية"),
TagFilter("4", "الحياة اليومية"),
TagFilter("5", "آليات"),
TagFilter("6", "تاريخي"),
TagFilter("7", "تراجيدي"),
TagFilter("8", "جوسيه"),
TagFilter("9", "حربي"),
TagFilter("10", "خيال"),
TagFilter("11", "خيال علمي"),
TagFilter("12", "دراما"),
TagFilter("13", "رعب"),
TagFilter("14", "رومانسي"),
TagFilter("15", "رياضة"),
TagFilter("16", "ساموراي"),
TagFilter("17", "سحر"),
TagFilter("18", "سينين"),
TagFilter("19", "شوجو"),
TagFilter("20", "شونين"),
TagFilter("21", "عنف"),
TagFilter("22", "غموض"),
TagFilter("23", "فنون قتال"),
TagFilter("24", "قوى خارقة"),
TagFilter("25", "كوميدي"),
TagFilter("26", "لعبة"),
TagFilter("27", "مسابقة"),
TagFilter("28", "مصاصي الدماء"),
TagFilter("29", "مغامرات"),
TagFilter("30", "موسيقى"),
TagFilter("31", "نفسي"),
TagFilter("32", "نينجا"),
TagFilter("33", "وحوش"),
TagFilter("34", "حريم"),
TagFilter("35", "راشد"),
TagFilter("38", "ويب-تون"),
TagFilter("39", "زمنكاني")
)
)
private const val DATE_FILTER_PATTERN = "yyyy/MM/dd"
@SuppressLint("SimpleDateFormat")
private val DATE_FITLER_FORMAT = SimpleDateFormat(DATE_FILTER_PATTERN).apply {
isLenient = false
}
private fun SimpleDateFormat.isValid(date: String): Boolean {
return try {
this.parse(date)
true
} catch (e: ParseException) {
false
}
}
private fun JsonObject.addPropertyFromValidatingTextFilter(
filter: ValidatingTextFilter,
property: String,
invalidErrorMessage: String,
default: String? = null
) {
filter.let {
when {
it.state == "" -> if (default == null) {
addProperty(property, JsonNull.INSTANCE)
} else addProperty(property, default)
it.isValid() -> addProperty(property, it.state)
else -> throw Exception(invalidErrorMessage)
}
}
}
private inline fun <reified T> Iterable<*>.findInstance() = find { it is T } as? T
private class TagFilter(val id: String, name: String, state: Int = STATE_IGNORE) : Filter.TriState(name, state)
private abstract class ValidatingTextFilter(name: String) : Filter.Text(name) {
abstract fun isValid(): Boolean
}
private class DateFilter(val id: String, name: String) : ValidatingTextFilter("($DATE_FILTER_PATTERN) $name)") {
override fun isValid(): Boolean = DATE_FITLER_FORMAT.isValid(this.state)
}
private class IntFilter(val id: String, name: String) : ValidatingTextFilter(name) {
override fun isValid(): Boolean = state.toIntOrNull() != null
}
}
}

View File

@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.extension.ar.gmanga
import android.app.Application
import android.content.SharedPreferences
import android.support.v7.preference.ListPreference
import android.support.v7.preference.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class GmangaPreferences(id: Long) {
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
fun setupPreferenceScreen(screen: PreferenceScreen) {
STRING_PREFERENCES.forEach {
val preference = ListPreference(screen.context).apply {
key = it.key
title = it.title
entries = it.entries()
entryValues = it.entryValues()
summary = "%s"
}
if (!preferences.contains(it.key))
preferences.edit().putString(it.key, it.default().key).apply()
screen.addPreference(preference)
}
}
fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
STRING_PREFERENCES.forEach {
val preference = androidx.preference.ListPreference(screen.context).apply {
key = it.key
title = it.title
entries = it.entries()
entryValues = it.entryValues()
summary = "%s"
}
if (!preferences.contains(it.key))
preferences.edit().putString(it.key, it.default().key).apply()
screen.addPreference(preference)
}
}
fun getString(pref: StringPreference): String {
return preferences.getString(pref.key, pref.default().key)!!
}
companion object {
class StringPreferenceOption(val key: String, val title: String)
class StringPreference(
val key: String,
val title: String,
private val options: List<StringPreferenceOption>,
private val defaultOptionIndex: Int = 0
) {
fun entries(): Array<String> = options.map { it.title }.toTypedArray()
fun entryValues(): Array<String> = options.map { it.key }.toTypedArray()
fun default(): StringPreferenceOption = options[defaultOptionIndex]
}
// preferences
const val PREF_CHAPTER_LISTING_SHOW_ALL = "gmanga_gmanga_chapter_listing_show_all"
const val PREF_CHAPTER_LISTING_SHOW_POPULAR = "gmanga_chapter_listing_most_viewed"
val PREF_CHAPTER_LISTING = StringPreference(
"gmanga_chapter_listing",
"كيفية عرض الفصل بقائمة الفصول",
listOf(
StringPreferenceOption(PREF_CHAPTER_LISTING_SHOW_POPULAR, "اختيار النسخة الأكثر مشاهدة"),
StringPreferenceOption(PREF_CHAPTER_LISTING_SHOW_ALL, "عرض جميع النسخ")
)
)
private val STRING_PREFERENCES = listOf(
PREF_CHAPTER_LISTING
)
}
}