add MangaBall (#11344)
* MangaBall * remove * suggested changes and more * remove this * MangaBall: Fix Korean language code * change to Locale.ROOT as the pattern isn't language specific * only throw if filtered
This commit is contained in:
parent
9627718a40
commit
68b70d54d9
21
src/all/mangaball/AndroidManifest.xml
Normal file
21
src/all/mangaball/AndroidManifest.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.mangaball.UrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
|
||||
<data android:host="mangaball.net" />
|
||||
<data android:scheme="https" />
|
||||
<data android:pathPattern="/title-detail/..*" />
|
||||
<data android:pathPattern="/chapter-detail/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
8
src/all/mangaball/build.gradle
Normal file
8
src/all/mangaball/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'Manga Ball'
|
||||
extClass = '.MangaBallFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/mangaball/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/all/mangaball/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/all/mangaball/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/all/mangaball/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@ -0,0 +1,71 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class SearchResponse(
|
||||
val data: List<SearchManga>,
|
||||
private val pagination: Pagination,
|
||||
) {
|
||||
@Serializable
|
||||
class Pagination(
|
||||
@SerialName("current_page")
|
||||
val currentPage: Int,
|
||||
@SerialName("last_page")
|
||||
val lastPage: Int,
|
||||
)
|
||||
|
||||
fun hasNextPage() = pagination.currentPage < pagination.lastPage
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class SearchManga(
|
||||
val url: String,
|
||||
val name: String,
|
||||
val cover: String,
|
||||
val isAdult: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ChapterListResponse(
|
||||
@SerialName("ALL_CHAPTERS")
|
||||
val chapters: List<ChapterContainer>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ChapterContainer(
|
||||
@SerialName("number_float")
|
||||
val number: Float,
|
||||
val translations: List<Chapter>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Chapter(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val language: String,
|
||||
val group: Group,
|
||||
val date: String,
|
||||
val volume: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Group(
|
||||
@SerialName("_id")
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Yoast(
|
||||
@SerialName("@graph")
|
||||
val graph: List<Graph>,
|
||||
) {
|
||||
@Serializable
|
||||
class Graph(
|
||||
@SerialName("@type")
|
||||
val type: String,
|
||||
val url: String? = null,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,201 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
abstract class SelectFilter<T>(
|
||||
name: String,
|
||||
private val options: List<Pair<String, T>>,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
options.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
val selected get() = options[state].second
|
||||
}
|
||||
|
||||
class TriStateFilter<T>(name: String, val value: T) : Filter.TriState(name)
|
||||
|
||||
abstract class TriStateGroupFilter<T>(
|
||||
name: String,
|
||||
options: List<Pair<String, T>>,
|
||||
) : Filter.Group<TriStateFilter<T>>(
|
||||
name,
|
||||
options.map { TriStateFilter(it.first, it.second) },
|
||||
) {
|
||||
val included get() = state.filter { it.isIncluded() }.map { it.value }
|
||||
val excluded get() = state.filter { it.isExcluded() }.map { it.value }
|
||||
}
|
||||
|
||||
class SortFilter : SelectFilter<String>(
|
||||
"Sort By",
|
||||
options = listOf(
|
||||
"Lastest Updated Chapters" to "updated_chapters_desc",
|
||||
"Oldest Updated Chapters" to "updated_chapters_asc",
|
||||
"Lastest Created" to "created_at_desc",
|
||||
"Oldest Created" to "created_at_asc",
|
||||
"Title A-Z" to "name_asc",
|
||||
"Title Z-A" to "name_desc",
|
||||
"Views High to Low" to "views_desc",
|
||||
"Views Low to High" to "views_asc",
|
||||
),
|
||||
)
|
||||
|
||||
class ContentFilter : TriStateGroupFilter<String>(
|
||||
"Content",
|
||||
options = listOf(
|
||||
"Gore" to "685148d115e8b86aae68e4f3",
|
||||
"Sexual Violence" to "685146c5f3ed681c80f257e7",
|
||||
),
|
||||
)
|
||||
|
||||
class FormatFilter : TriStateGroupFilter<String>(
|
||||
"Format",
|
||||
options = listOf(
|
||||
"4-Koma" to "685148d115e8b86aae68e4ec",
|
||||
"Adaptation" to "685148cf15e8b86aae68e4de",
|
||||
"Anthology" to "685148e915e8b86aae68e558",
|
||||
"Award Winning" to "685148fe15e8b86aae68e5a7",
|
||||
"Doujinshi" to "6851490e15e8b86aae68e5da",
|
||||
"Fan Colored" to "6851498215e8b86aae68e704",
|
||||
"Full Color" to "685148d615e8b86aae68e502",
|
||||
"Long Strip" to "685148d915e8b86aae68e517",
|
||||
"Official Colored" to "6851493515e8b86aae68e64a",
|
||||
"Oneshot" to "685148eb15e8b86aae68e56c",
|
||||
"Self-Published" to "6851492e15e8b86aae68e633",
|
||||
"Web Comic" to "685148d715e8b86aae68e50d",
|
||||
),
|
||||
)
|
||||
|
||||
class GenreFilter : TriStateGroupFilter<String>(
|
||||
"Genre",
|
||||
options = listOf(
|
||||
"Action" to "685146c5f3ed681c80f257e3",
|
||||
"Adult" to "689371f0a943baf927094f03",
|
||||
"Adventure" to "685146c5f3ed681c80f257e6",
|
||||
"Boys' Love" to "685148ef15e8b86aae68e573",
|
||||
"Comedy" to "685146c5f3ed681c80f257e5",
|
||||
"Crime" to "685148da15e8b86aae68e51f",
|
||||
"Drama" to "685148cf15e8b86aae68e4dd",
|
||||
"Ecchi" to "6892a73ba943baf927094e37",
|
||||
"Fantasy" to "685146c5f3ed681c80f257ea",
|
||||
"Girls' Love" to "685148da15e8b86aae68e524",
|
||||
"Historical" to "685148db15e8b86aae68e527",
|
||||
"Horror" to "685148da15e8b86aae68e520",
|
||||
"Isekai" to "685146c5f3ed681c80f257e9",
|
||||
"Magical Girls" to "6851490d15e8b86aae68e5d4",
|
||||
"Mature" to "68932d11a943baf927094e7b",
|
||||
"Mecha" to "6851490c15e8b86aae68e5d2",
|
||||
"Medical" to "6851494e15e8b86aae68e66e",
|
||||
"Mystery" to "685148d215e8b86aae68e4f4",
|
||||
"Philosophical" to "685148e215e8b86aae68e544",
|
||||
"Psychological" to "685148d715e8b86aae68e507",
|
||||
"Romance" to "685148cf15e8b86aae68e4db",
|
||||
"Sci-Fi" to "685148cf15e8b86aae68e4da",
|
||||
"Shounen Ai" to "689f0ab1f2e66744c6091524",
|
||||
"Slice of Life" to "685148d015e8b86aae68e4e3",
|
||||
"Smut" to "689371f2a943baf927094f04",
|
||||
"Sports" to "685148f515e8b86aae68e588",
|
||||
"Superhero" to "6851492915e8b86aae68e61c",
|
||||
"Thriller" to "685148d915e8b86aae68e51e",
|
||||
"Tragedy" to "685148db15e8b86aae68e529",
|
||||
"User Created" to "68932c3ea943baf927094e77",
|
||||
"Wuxia" to "6851490715e8b86aae68e5c3",
|
||||
"Yaoi" to "68932f68a943baf927094eaa",
|
||||
"Yuri" to "6896a885a943baf927094f66",
|
||||
),
|
||||
)
|
||||
|
||||
class OriginFilter : TriStateGroupFilter<String>(
|
||||
"Origin",
|
||||
options = listOf(
|
||||
"Comic" to "68ecab8507ec62d87e62780f",
|
||||
"Manga" to "68ecab1e07ec62d87e627806",
|
||||
"Manhua" to "68ecab4807ec62d87e62780b",
|
||||
"Manhwa" to "68ecab3b07ec62d87e627809",
|
||||
),
|
||||
)
|
||||
|
||||
class ThemeFilter : TriStateGroupFilter<String>(
|
||||
"Theme",
|
||||
options = listOf(
|
||||
"Aliens" to "6851490d15e8b86aae68e5d5",
|
||||
"Animals" to "685148e715e8b86aae68e54b",
|
||||
"Comics" to "68bf09ff8fdeab0b6a9bc2b7",
|
||||
"Cooking" to "685148d215e8b86aae68e4f8",
|
||||
"Crossdressing" to "685148df15e8b86aae68e534",
|
||||
"Delinquents" to "685148d915e8b86aae68e519",
|
||||
"Demons" to "685146c5f3ed681c80f257e4",
|
||||
"Genderswap" to "685148d715e8b86aae68e505",
|
||||
"Ghosts" to "685148d615e8b86aae68e501",
|
||||
"Gyaru" to "685148d015e8b86aae68e4e8",
|
||||
"Harem" to "685146c5f3ed681c80f257e8",
|
||||
"Hentai" to "68bfceaf4dbc442a26519889",
|
||||
"Incest" to "685148f215e8b86aae68e584",
|
||||
"Loli" to "685148d715e8b86aae68e506",
|
||||
"Mafia" to "685148d915e8b86aae68e518",
|
||||
"Magic" to "685148d715e8b86aae68e509",
|
||||
"Manhwa 18+" to "68f5f5ce5f29d3c1863dec3a",
|
||||
"Martial Arts" to "6851490615e8b86aae68e5c2",
|
||||
"Military" to "685148e215e8b86aae68e541",
|
||||
"Monster Girls" to "685148db15e8b86aae68e52c",
|
||||
"Monsters" to "685146c5f3ed681c80f257e2",
|
||||
"Music" to "685148d015e8b86aae68e4e4",
|
||||
"Ninja" to "685148d715e8b86aae68e508",
|
||||
"Office Workers" to "685148d315e8b86aae68e4fd",
|
||||
"Police" to "6851498815e8b86aae68e714",
|
||||
"Post-Apocalyptic" to "685148e215e8b86aae68e540",
|
||||
"Reincarnation" to "685146c5f3ed681c80f257e1",
|
||||
"Reverse Harem" to "685148df15e8b86aae68e533",
|
||||
"Samurai" to "6851490415e8b86aae68e5b9",
|
||||
"School Life" to "685148d015e8b86aae68e4e7",
|
||||
"Shota" to "685148d115e8b86aae68e4ed",
|
||||
"Supernatural" to "685148db15e8b86aae68e528",
|
||||
"Survival" to "685148cf15e8b86aae68e4dc",
|
||||
"Time Travel" to "6851490c15e8b86aae68e5d1",
|
||||
"Traditional Games" to "6851493515e8b86aae68e645",
|
||||
"Vampires" to "685148f915e8b86aae68e597",
|
||||
"Video Games" to "685148e115e8b86aae68e53c",
|
||||
"Villainess" to "6851492115e8b86aae68e602",
|
||||
"Virtual Reality" to "68514a1115e8b86aae68e83e",
|
||||
"Zombies" to "6851490c15e8b86aae68e5d3",
|
||||
),
|
||||
)
|
||||
|
||||
class TagIncludeMode : SelectFilter<String>(
|
||||
"Tag Include Mode",
|
||||
options = listOf(
|
||||
"AND" to "and",
|
||||
"OR" to "or",
|
||||
),
|
||||
)
|
||||
|
||||
class TagExcludeMode : SelectFilter<String>(
|
||||
"Tag Exclude Mode",
|
||||
options = listOf(
|
||||
"AND" to "and",
|
||||
"OR" to "or",
|
||||
),
|
||||
)
|
||||
|
||||
class DemographicFilter : SelectFilter<String>(
|
||||
"Magazine Demographic",
|
||||
options = listOf(
|
||||
"Any" to "any",
|
||||
"Shounen" to "shounen",
|
||||
"Shoujo" to "shoujo",
|
||||
"Seinen" to "seinen",
|
||||
"Josei" to "josei",
|
||||
"Yuri" to "yuri",
|
||||
"Yaoi" to "yaoi",
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : SelectFilter<String>(
|
||||
"Publication Status",
|
||||
options = listOf(
|
||||
"Any" to "any",
|
||||
"Ongoing" to "ongoing",
|
||||
"Completed" to "completed",
|
||||
"Hiatus" to "hiatus",
|
||||
"Cancelled" to "cancelled",
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,403 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
import android.util.Log
|
||||
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.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class MangaBall(
|
||||
override val lang: String,
|
||||
private vararg val siteLang: String,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val name = "Manga Ball"
|
||||
override val baseUrl = "https://mangaball.net"
|
||||
override val supportsLatest = true
|
||||
private val preferences by getPreferencesLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
var request = chain.request()
|
||||
if (request.url.pathSegments[0] == "api") {
|
||||
request = request.newBuilder()
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("X-CSRF-TOKEN", getCSRF())
|
||||
.build()
|
||||
|
||||
val response = chain.proceed(request)
|
||||
if (!response.isSuccessful && response.code == 403) {
|
||||
response.close()
|
||||
request = request.newBuilder()
|
||||
.header("X-CSRF-TOKEN", getCSRF(forceReset = true))
|
||||
.build()
|
||||
|
||||
chain.proceed(request)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private var _csrf: String? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getCSRF(document: Document? = null, forceReset: Boolean = false): String {
|
||||
if (_csrf == null || document != null || forceReset) {
|
||||
val doc = document ?: client.newCall(
|
||||
GET(baseUrl, headers),
|
||||
).execute().asJsoup()
|
||||
|
||||
doc.selectFirst("meta[name=csrf-token]")
|
||||
?.attr("content")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.also { _csrf = it }
|
||||
}
|
||||
|
||||
return _csrf ?: throw Exception("CSRF token not found")
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val filters = getFilterList().apply {
|
||||
firstInstance<SortFilter>().state = 6
|
||||
}
|
||||
|
||||
return searchMangaRequest(page, "", filters)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response) =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
searchMangaRequest(page, "", getFilterList())
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith("https://")) {
|
||||
deepLink(query)
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val body = FormBody.Builder().apply {
|
||||
add("search_input", query.trim())
|
||||
add("filters[sort]", filters.firstInstance<SortFilter>().selected)
|
||||
add("filters[page]", page.toString())
|
||||
filters.filterIsInstance<TriStateGroupFilter<String>>().forEach { tags ->
|
||||
tags.included.forEach { tag ->
|
||||
add("filters[tag_included_ids][]", tag)
|
||||
}
|
||||
}
|
||||
add("filters[tag_included_mode]", filters.firstInstance<TagIncludeMode>().selected)
|
||||
filters.filterIsInstance<TriStateGroupFilter<String>>().forEach { tags ->
|
||||
tags.excluded.forEach { tag ->
|
||||
add("filters[tag_excluded_ids][]", tag)
|
||||
}
|
||||
}
|
||||
add("filters[tag_excluded_mode]", filters.firstInstance<TagExcludeMode>().selected)
|
||||
add("filters[contentRating]", "any")
|
||||
add("filters[demographic]", filters.firstInstance<DemographicFilter>().selected)
|
||||
add("filters[person]", "any")
|
||||
add("filters[publicationYear]", "")
|
||||
add("filters[publicationStatus]", filters.firstInstance<StatusFilter>().selected)
|
||||
siteLang.forEach {
|
||||
add("filters[translatedLanguage][]", it)
|
||||
}
|
||||
}.build()
|
||||
|
||||
return POST("$baseUrl/api/v1/title/search-advanced/", headers, body)
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
SortFilter(),
|
||||
DemographicFilter(),
|
||||
StatusFilter(),
|
||||
ContentFilter(),
|
||||
FormatFilter(),
|
||||
GenreFilter(),
|
||||
OriginFilter(),
|
||||
ThemeFilter(),
|
||||
TagIncludeMode(),
|
||||
TagExcludeMode(),
|
||||
)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<SearchResponse>()
|
||||
val hideNsfw = hideNsfwPreference()
|
||||
|
||||
val mangas = data.data
|
||||
.filterNot {
|
||||
it.isAdult && hideNsfw
|
||||
}
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
url = it.url.toHttpUrl().pathSegments[1]
|
||||
title = it.name
|
||||
thumbnail_url = it.cover
|
||||
}
|
||||
}
|
||||
|
||||
if (mangas.isEmpty() && hideNsfw) {
|
||||
throw Exception("All results filtered out due to nsfw filter")
|
||||
}
|
||||
|
||||
return MangasPage(mangas, data.hasNextPage())
|
||||
}
|
||||
|
||||
private fun deepLink(url: String): Observable<MangasPage> {
|
||||
val httpUrl = url.toHttpUrl()
|
||||
if (
|
||||
httpUrl.host == baseUrl.toHttpUrl().host &&
|
||||
httpUrl.pathSegments.size >= 2 &&
|
||||
httpUrl.pathSegments[0] in listOf("title-detail", "chapter-detail")
|
||||
) {
|
||||
val slug = if (httpUrl.pathSegments[0] == "title-detail") {
|
||||
httpUrl.pathSegments[1]
|
||||
} else {
|
||||
client.newCall(GET(httpUrl, headers)).execute()
|
||||
.use { response ->
|
||||
response.asJsoup()
|
||||
.selectFirst(".yoast-schema-graph")!!.data()
|
||||
.parseAs<Yoast>()
|
||||
.graph.first { it.type == "WebPage" }
|
||||
.url!!.toHttpUrl()
|
||||
.pathSegments[1]
|
||||
}
|
||||
}
|
||||
|
||||
val manga = SManga.create().apply {
|
||||
this.url = slug
|
||||
}
|
||||
|
||||
return fetchMangaDetails(manga).map {
|
||||
MangasPage(listOf(it), false)
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unsupported url")
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(getMangaUrl(manga), headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl/title-detail/${manga.url}/"
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
getCSRF(document)
|
||||
|
||||
return SManga.create().apply {
|
||||
url = document.location().toHttpUrl().pathSegments[1]
|
||||
title = document.selectFirst("#comicDetail h6")!!.ownText()
|
||||
thumbnail_url = document.selectFirst("img.featured-cover")?.absUrl("src")
|
||||
genre = buildList {
|
||||
document.selectFirst("#featuredComicsCarousel img[src*=/flags/]")
|
||||
?.attr("src")?.also {
|
||||
when {
|
||||
it.contains("jp") -> add("Manga")
|
||||
it.contains("kr") -> add("Manhwa")
|
||||
it.contains("cn") -> add("Manhua")
|
||||
}
|
||||
}
|
||||
document.select("#comicDetail span[data-tag-id]")
|
||||
.mapTo(this) { it.ownText() }
|
||||
}.joinToString()
|
||||
author = document.select("#comicDetail span[data-person-id]")
|
||||
.eachText().joinToString()
|
||||
description = buildString {
|
||||
document.selectFirst("#descriptionContent p")
|
||||
?.also { append(it.wholeText()) }
|
||||
document.selectFirst("#comicDetail span.badge:contains(Published)")
|
||||
?.also { append("\n\n", it.text()) }
|
||||
val titles = document.select("div.alternate-name-container").text().split("/")
|
||||
if (titles.isNotEmpty()) {
|
||||
append("\n\nAlternative Names: \n")
|
||||
titles.forEach {
|
||||
append("- ", it.trim(), "\n")
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
status = when (document.selectFirst("span.badge-status")?.text()) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Completed" -> SManga.COMPLETED
|
||||
"Hiatus" -> SManga.ON_HIATUS
|
||||
"Cancelled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = manga.url.substringAfterLast("-")
|
||||
val body = FormBody.Builder()
|
||||
.add("title_id", id)
|
||||
.build()
|
||||
|
||||
return POST("$baseUrl/api/v1/chapter/chapter-listing-by-title-id/", headers, body)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
(response.request.body as FormBody).also {
|
||||
updateViews(it.value(0))
|
||||
}
|
||||
|
||||
val data = response.parseAs<ChapterListResponse>()
|
||||
|
||||
return data.chapters.flatMap { chapter ->
|
||||
chapter.translations.mapNotNull { translation ->
|
||||
if (translation.language in siteLang) {
|
||||
SChapter.create().apply {
|
||||
url = translation.id
|
||||
name = buildString {
|
||||
if (translation.volume > 0) {
|
||||
append("Vol. ")
|
||||
append(translation.volume)
|
||||
append(" ")
|
||||
}
|
||||
val number = chapter.number.toString().removeSuffix(".0")
|
||||
if (translation.name.contains(number)) {
|
||||
append(translation.name.trim())
|
||||
} else {
|
||||
append("Ch. ")
|
||||
append(number)
|
||||
append(" ")
|
||||
append(translation.name.trim())
|
||||
}
|
||||
}
|
||||
chapter_number = chapter.number
|
||||
date_upload = dateFormat.tryParse(translation.date)
|
||||
scanlator = buildString {
|
||||
append(translation.group.name)
|
||||
// id is usually the name of the site the chapter was scraped from
|
||||
// if not then it is generated id of an active group on the site
|
||||
if (groupIdRegex.matchEntire(translation.group.id) == null) {
|
||||
append(" (")
|
||||
append(translation.group.id)
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val groupIdRegex = Regex("""[a-z0-9]{24}""")
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(getChapterUrl(chapter), headers)
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
return "$baseUrl/chapter-detail/${chapter.url}/"
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
getCSRF(document)
|
||||
|
||||
document.select("script:containsData(titleId)").joinToString(";") { it.data() }.also {
|
||||
val titleId = titleIdRegex.find(it)
|
||||
?.groupValues?.get(1)
|
||||
?: return@also
|
||||
val chapterId = chapterIdRegex.find(it)
|
||||
?.groupValues?.get(1)
|
||||
?: return@also
|
||||
|
||||
updateViews(titleId, chapterId)
|
||||
}
|
||||
|
||||
val script = document.select("script:containsData(chapterImages)").joinToString(";") { it.data() }
|
||||
val images = imagesRegex.find(script)
|
||||
?.groupValues?.get(1)
|
||||
?.parseAs<List<String>>()
|
||||
.orEmpty()
|
||||
|
||||
return images.mapIndexed { idx, img ->
|
||||
Page(idx, imageUrl = img)
|
||||
}
|
||||
}
|
||||
|
||||
private val imagesRegex = Regex("""const\s+chapterImages\s*=\s*JSON\.parse\(`([^`]+)`\)""")
|
||||
private val titleIdRegex = Regex("""const\s+titleId\s*=\s*`([^`]+)`;""")
|
||||
private val chapterIdRegex = Regex("""const\s+chapterId\s*=\s*`([^`]+)`;""")
|
||||
|
||||
private fun updateViews(titleId: String, chapterId: String = "") {
|
||||
val body = FormBody.Builder()
|
||||
.add("title_id", titleId)
|
||||
.add("chapter_id", chapterId)
|
||||
.build()
|
||||
|
||||
val request = POST("$baseUrl/api/v1/views/update/", headers, body)
|
||||
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.closeQuietly()
|
||||
}
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(name, "Failed to update views", e)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = NSFW_PREF
|
||||
title = "Hide NSFW content"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private fun hideNsfwPreference() = preferences.getBoolean(NSFW_PREF, false)
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
private const val NSFW_PREF = "nsfw_pref"
|
||||
@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class MangaBallFactory : SourceFactory {
|
||||
override fun createSources() = listOf(
|
||||
MangaBall("ar", "ar"),
|
||||
MangaBall("bg", "bg"),
|
||||
MangaBall("bn", "bn"),
|
||||
MangaBall("ca", "ca", "ca-ad", "ca-es", "ca-fr", "ca-it", "ca-pt"),
|
||||
MangaBall("cs", "cs"),
|
||||
MangaBall("da", "da"),
|
||||
MangaBall("de", "de"),
|
||||
MangaBall("el", "el"),
|
||||
MangaBall("en", "en"),
|
||||
MangaBall("es", "es", "es-ar", "es-mx", "es-es", "es-la", "es-419"),
|
||||
MangaBall("fa", "fa"),
|
||||
MangaBall("fi", "fi"),
|
||||
MangaBall("fr", "fr"),
|
||||
MangaBall("he", "he"),
|
||||
MangaBall("hi", "hi"),
|
||||
MangaBall("hu", "hu"),
|
||||
MangaBall("id", "id"),
|
||||
MangaBall("it", "it", "it-it"),
|
||||
MangaBall("is", "ib", "ib-is", "is"),
|
||||
MangaBall("ja", "jp"),
|
||||
MangaBall("ko", "kr"),
|
||||
MangaBall("kn", "kn", "kn-in", "kn-my", "kn-sg", "kn-tw"),
|
||||
MangaBall("ml", "ml", "ml-in", "ml-my", "ml-sg", "ml-tw"),
|
||||
MangaBall("ms", "ms"),
|
||||
MangaBall("ne", "ne"),
|
||||
MangaBall("nl", "nl", "nl-be"),
|
||||
MangaBall("no", "no"),
|
||||
MangaBall("pl", "pl"),
|
||||
MangaBall("pt-BR", "pt-br", "pt-pt"),
|
||||
MangaBall("ro", "ro"),
|
||||
MangaBall("ru", "ru"),
|
||||
MangaBall("sk", "sk"),
|
||||
MangaBall("sl", "sl"),
|
||||
MangaBall("sq", "sq"),
|
||||
MangaBall("sr", "sr", "sr-cyrl"),
|
||||
MangaBall("sv", "sv"),
|
||||
MangaBall("ta", "ta"),
|
||||
MangaBall("th", "th", "th-hk", "th-kh", "th-la", "th-my", "th-sg"),
|
||||
MangaBall("tr", "tr"),
|
||||
MangaBall("uk", "uk"),
|
||||
MangaBall("vi", "vi"),
|
||||
MangaBall("zh", "zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
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 UrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", intent.data.toString())
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("MangaBall", "Unable to launch activity", e)
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user