Initial commit
23
src/all/akuma/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.akuma.AkumaUrlActivity"
|
||||
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="akuma.moe"
|
||||
android:pathPattern="/g/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
12
src/all/akuma/build.gradle
Normal file
@@ -0,0 +1,12 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Akuma'
|
||||
pkgNameSuffix = 'all.akuma'
|
||||
extClass = '.Akuma'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/akuma/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src/all/akuma/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/all/akuma/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/all/akuma/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/all/akuma/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src/all/akuma/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,221 @@
|
||||
package eu.kanade.tachiyomi.extension.all.akuma
|
||||
|
||||
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.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.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.io.IOException
|
||||
|
||||
class Akuma : ParsedHttpSource() {
|
||||
|
||||
override val name = "Akuma"
|
||||
|
||||
override val baseUrl = "https://akuma.moe"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
private var nextHash: String? = null
|
||||
|
||||
private var storedToken: String? = null
|
||||
|
||||
private val ddosGuardIntercept = DDosGuardInterceptor(network.client)
|
||||
|
||||
override val client: OkHttpClient = network.client.newBuilder()
|
||||
.addInterceptor(ddosGuardIntercept)
|
||||
.addInterceptor(::tokenInterceptor)
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
private fun tokenInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
if (request.method == "POST" && request.header("X-CSRF-TOKEN") == null) {
|
||||
val modifiedRequest = request.newBuilder()
|
||||
.addHeader("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
val token = getToken()
|
||||
val response = chain.proceed(
|
||||
modifiedRequest
|
||||
.addHeader("X-CSRF-TOKEN", token)
|
||||
.build(),
|
||||
)
|
||||
|
||||
if (!response.isSuccessful && response.code == 419) {
|
||||
response.close()
|
||||
storedToken = null // reset the token
|
||||
val newToken = getToken()
|
||||
return chain.proceed(
|
||||
modifiedRequest
|
||||
.addHeader("X-CSRF-TOKEN", newToken)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun getToken(): String {
|
||||
if (storedToken.isNullOrEmpty()) {
|
||||
val request = GET(baseUrl, headers)
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
val document = response.asJsoup()
|
||||
val token = document.select("head meta[name*=csrf-token]")
|
||||
.attr("content")
|
||||
|
||||
if (token.isEmpty()) {
|
||||
throw IOException("Unable to find CSRF token")
|
||||
}
|
||||
|
||||
storedToken = token
|
||||
}
|
||||
|
||||
return storedToken!!
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val payload = FormBody.Builder()
|
||||
.add("view", "3")
|
||||
.build()
|
||||
|
||||
return if (page == 1) {
|
||||
nextHash = null
|
||||
POST(baseUrl, headers, payload)
|
||||
} else {
|
||||
POST("$baseUrl/?cursor=$nextHash", headers, payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = ".post-loop li"
|
||||
override fun popularMangaNextPageSelector() = ".page-item a[rel*=next]"
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||
popularMangaFromElement(element)
|
||||
}
|
||||
|
||||
val nextUrl = document.select(popularMangaNextPageSelector()).first()?.attr("href")
|
||||
|
||||
nextHash = nextUrl?.toHttpUrlOrNull()?.queryParameter("cursor")
|
||||
|
||||
return MangasPage(mangas, !nextHash.isNullOrEmpty())
|
||||
}
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(element.select("a").attr("href"))
|
||||
title = element.select(".overlay-title").text()
|
||||
thumbnail_url = element.select("img").attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_ID)) {
|
||||
val url = "/g/${query.substringAfter(PREFIX_ID)}"
|
||||
val manga = SManga.create().apply { this.url = url }
|
||||
fetchMangaDetails(manga).map {
|
||||
MangasPage(listOf(it.apply { this.url = url }), false)
|
||||
}
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val request = popularMangaRequest(page)
|
||||
|
||||
val url = request.url.newBuilder()
|
||||
.addQueryParameter("q", query.trim())
|
||||
.build()
|
||||
|
||||
return request.newBuilder()
|
||||
.url(url)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.select(".entry-title").text()
|
||||
thumbnail_url = document.select(".img-thumbnail").attr("abs:src")
|
||||
author = document.select("li.meta-data > span.artist + span.value").text()
|
||||
genre = document.select(".info-list a").joinToString { it.text() }
|
||||
description = document.select(".pages span.value").text() + " Pages"
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.COMPLETED
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.just(
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/1"
|
||||
name = "Chapter"
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val totalPages = document.select(".nav-select option").last()
|
||||
?.attr("value")?.toIntOrNull() ?: return emptyList()
|
||||
|
||||
val url = document.location().substringBeforeLast("/")
|
||||
|
||||
val pageList = mutableListOf<Page>()
|
||||
|
||||
for (i in 1..totalPages) {
|
||||
pageList.add(Page(i, "$url/$i"))
|
||||
}
|
||||
|
||||
return pageList
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
return document.select(".entry-content img").attr("abs:src")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID = "id:"
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.extension.all.akuma
|
||||
|
||||
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 AkumaUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${Akuma.PREFIX_ID}$id")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("AkumaUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("AkumaUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.kanade.tachiyomi.extension.all.akuma
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
|
||||
class DDosGuardInterceptor(private val client: OkHttpClient) : Interceptor {
|
||||
|
||||
private val cookieManager by lazy { CookieManager.getInstance() }
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val response = chain.proceed(originalRequest)
|
||||
|
||||
// Check if DDos-GUARD is on
|
||||
if (response.code !in ERROR_CODES || response.header("Server") !in SERVER_CHECK) {
|
||||
return response
|
||||
}
|
||||
|
||||
val cookies = cookieManager.getCookie(originalRequest.url.toString())
|
||||
val oldCookie = if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(originalRequest.url, it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val ddg2Cookie = oldCookie.firstOrNull { it.name == "__ddg2_" }
|
||||
if (!ddg2Cookie?.value.isNullOrEmpty()) {
|
||||
return response
|
||||
}
|
||||
|
||||
response.close()
|
||||
|
||||
val newCookie = getNewCookie(originalRequest.url)
|
||||
?: return chain.proceed(originalRequest)
|
||||
|
||||
val newCookieHeader = (oldCookie + newCookie)
|
||||
.joinToString("; ") { "${it.name}=${it.value}" }
|
||||
|
||||
val modifiedRequest = originalRequest.newBuilder()
|
||||
.header("cookie", newCookieHeader)
|
||||
.build()
|
||||
|
||||
return chain.proceed(modifiedRequest)
|
||||
}
|
||||
|
||||
private fun getNewCookie(url: HttpUrl): Cookie? {
|
||||
val wellKnown = client.newCall(GET(wellKnownUrl))
|
||||
.execute().body.string()
|
||||
.substringAfter("'", "")
|
||||
.substringBefore("'", "")
|
||||
val checkUrl = "${url.scheme}://${url.host + wellKnown}"
|
||||
val response = client.newCall(GET(checkUrl)).execute()
|
||||
return response.header("set-cookie")?.let {
|
||||
Cookie.parse(url, it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val wellKnownUrl = "https://check.ddos-guard.net/check.js"
|
||||
private val ERROR_CODES = listOf(403)
|
||||
private val SERVER_CHECK = listOf("ddos-guard")
|
||||
}
|
||||
}
|
||||
53
src/all/batoto/AndroidManifest.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.batoto.BatoToUrlActivity"
|
||||
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="*.bato.to" />
|
||||
<data android:host="bato.to" />
|
||||
<data android:host="*.batocc.com" />
|
||||
<data android:host="batocc.com" />
|
||||
<data android:host="*.batotoo.com" />
|
||||
<data android:host="batotoo.com" />
|
||||
<data android:host="*.batotwo.com" />
|
||||
<data android:host="batotwo.com" />
|
||||
<data android:host="*.battwo.com" />
|
||||
<data android:host="battwo.com" />
|
||||
<data android:host="*.comiko.net" />
|
||||
<data android:host="comiko.net" />
|
||||
<data android:host="*.mangatoto.com" />
|
||||
<data android:host="mangatoto.com" />
|
||||
<data android:host="*.mangatoto.net" />
|
||||
<data android:host="mangatoto.net" />
|
||||
<data android:host="*.mangatoto.org" />
|
||||
<data android:host="mangatoto.org" />
|
||||
<data android:host="*.mycordant.co.uk" />
|
||||
<data android:host="mycordant.co.uk" />
|
||||
<data android:host="*.dto.to" />
|
||||
<data android:host="dto.to" />
|
||||
<data android:host="*.hto.to" />
|
||||
<data android:host="hto.to" />
|
||||
<data android:host="*.mto.to" />
|
||||
<data android:host="mto.to" />
|
||||
<data android:host="*.wto.to" />
|
||||
<data android:host="wto.to" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/series/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:pathPattern="/subject-overview/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
201
src/all/batoto/CHANGELOG.md
Normal file
@@ -0,0 +1,201 @@
|
||||
## 1.3.30
|
||||
|
||||
### Refactor
|
||||
|
||||
* Replace CryptoJS with Native Kotlin Functions
|
||||
* Remove QuickJS dependency
|
||||
|
||||
## 1.3.29
|
||||
|
||||
### Refactor
|
||||
|
||||
* Cleanup pageListParse function
|
||||
* Replace Duktape with QuickJS
|
||||
|
||||
## 1.3.28
|
||||
|
||||
### Features
|
||||
|
||||
* Add mirror `batocc.com`
|
||||
* Add mirror `batotwo.com`
|
||||
* Add mirror `mangatoto.net`
|
||||
* Add mirror `mangatoto.org`
|
||||
* Add mirror `mycordant.co.uk`
|
||||
* Add mirror `dto.to`
|
||||
* Add mirror `hto.to`
|
||||
* Add mirror `mto.to`
|
||||
* Add mirror `wto.to`
|
||||
* Remove mirror `mycdhands.com`
|
||||
|
||||
## 1.3.27
|
||||
|
||||
### Features
|
||||
|
||||
* Change default popular sort by `Most Views Totally`
|
||||
|
||||
## 1.3.26
|
||||
|
||||
### Fix
|
||||
|
||||
* Update author and artist parsing
|
||||
|
||||
## 1.3.25
|
||||
|
||||
### Fix
|
||||
|
||||
* Status parsing
|
||||
* Artist name parsing
|
||||
|
||||
## 1.3.24
|
||||
|
||||
### Fix
|
||||
|
||||
* Bump versions for individual extension with URL handler activities
|
||||
|
||||
## 1.2.23
|
||||
|
||||
### Fix
|
||||
|
||||
* Update pageListParse logic to handle website changes
|
||||
|
||||
## 1.2.22
|
||||
|
||||
### Features
|
||||
|
||||
* Add `CHANGELOG.md` & `README.md`
|
||||
|
||||
## 1.2.21
|
||||
|
||||
### Fix
|
||||
|
||||
* Update lang codes
|
||||
|
||||
## 1.2.20
|
||||
|
||||
### Features
|
||||
|
||||
* Rework of search
|
||||
|
||||
## 1.2.19
|
||||
|
||||
### Features
|
||||
|
||||
* Support for alternative chapter list
|
||||
* Personal lists filter
|
||||
|
||||
## 1.2.18
|
||||
|
||||
### Features
|
||||
|
||||
* Utils lists filter
|
||||
* Letter matching filter
|
||||
|
||||
## 1.2.17
|
||||
|
||||
### Features
|
||||
|
||||
* Add mirror `mycdhands.com`
|
||||
|
||||
## 1.2.16
|
||||
|
||||
### Features
|
||||
|
||||
* Mirror support
|
||||
* URL intent updates
|
||||
|
||||
## 1.2.15
|
||||
|
||||
### Fix
|
||||
|
||||
* Manga description
|
||||
|
||||
## 1.2.14
|
||||
|
||||
### Features
|
||||
|
||||
* Escape entities
|
||||
|
||||
## 1.2.13
|
||||
|
||||
### Refactor
|
||||
|
||||
* Replace Gson with kotlinx.serialization
|
||||
|
||||
## 1.2.12
|
||||
|
||||
### Fix
|
||||
|
||||
* Infinity search
|
||||
|
||||
## 1.2.11
|
||||
|
||||
### Fix
|
||||
|
||||
* No search result
|
||||
|
||||
## 1.2.10
|
||||
|
||||
### Features
|
||||
|
||||
* Support for URL intent
|
||||
* Updated filters
|
||||
|
||||
## 1.2.9
|
||||
|
||||
### Fix
|
||||
|
||||
* Chapter parsing
|
||||
|
||||
## 1.2.8
|
||||
|
||||
### Features
|
||||
|
||||
* More chapter filtering
|
||||
|
||||
## 1.2.7
|
||||
|
||||
### Fix
|
||||
|
||||
* Language filtering in latest
|
||||
* Parsing of seconds
|
||||
|
||||
## 1.2.6
|
||||
|
||||
### Features
|
||||
|
||||
* Scanlator support
|
||||
|
||||
### Fix
|
||||
|
||||
* Date parsing
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Features
|
||||
|
||||
* Update supported Language list
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Features
|
||||
|
||||
* Support for excluding genres
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### Fix
|
||||
|
||||
* Typo in some genres
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Features
|
||||
|
||||
* Reworked filter option
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Features
|
||||
|
||||
* Conversion from Emerald to Bato.to
|
||||
* First version
|
||||
20
src/all/batoto/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Bato.to
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?](#why-are-there-manga-of-diffrent-languge-than-the-selected-one-in-personal--utils-lists)
|
||||
- [Bato.to is not loading anything?](#batoto-is-not-loading-anything)
|
||||
|
||||
[Uncomment this if needed; and replace ( and ) with ( and )]: <> (- [Guides](#Guides))
|
||||
|
||||
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why are there Manga of diffrent languge than the selected one in Personal & Utils lists?
|
||||
Personol & Utils lists have no way to difritiate between langueges.
|
||||
|
||||
### Bato.to is not loading anything?
|
||||
Bato.to get blocked by some ISPs, try using a diffrent mirror of Bato.to from the settings.
|
||||
|
||||
[Uncomment this if needed]: <> (## Guides)
|
||||
17
src/all/batoto/build.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Bato.to'
|
||||
pkgNameSuffix = 'all.batoto'
|
||||
extClass = '.BatoToFactory'
|
||||
extVersionCode = 33
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib-cryptoaes'))
|
||||
}
|
||||
BIN
src/all/batoto/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/all/batoto/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/all/batoto/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src/all/batoto/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
src/all/batoto/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/all/batoto/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,974 @@
|
||||
package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||
import eu.kanade.tachiyomi.lib.cryptoaes.Deobfuscator
|
||||
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.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.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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 org.jsoup.parser.Parser
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class BatoTo(
|
||||
final override val lang: String,
|
||||
private val siteLang: String,
|
||||
) : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val name: String = "Bato.to"
|
||||
override val baseUrl: String = getMirrorPref()!!
|
||||
override val id: Long = when (lang) {
|
||||
"zh-Hans" -> 2818874445640189582
|
||||
"zh-Hant" -> 38886079663327225
|
||||
"ro-MD" -> 8871355786189601023
|
||||
else -> super.id
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val mirrorPref = ListPreference(screen.context).apply {
|
||||
key = "${MIRROR_PREF_KEY}_$lang"
|
||||
title = MIRROR_PREF_TITLE
|
||||
entries = MIRROR_PREF_ENTRIES
|
||||
entryValues = MIRROR_PREF_ENTRY_VALUES
|
||||
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString("${MIRROR_PREF_KEY}_$lang", entry).commit()
|
||||
}
|
||||
}
|
||||
val altChapterListPref = CheckBoxPreference(screen.context).apply {
|
||||
key = "${ALT_CHAPTER_LIST_PREF_KEY}_$lang"
|
||||
title = ALT_CHAPTER_LIST_PREF_TITLE
|
||||
summary = ALT_CHAPTER_LIST_PREF_SUMMARY
|
||||
setDefaultValue(ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", checkValue).commit()
|
||||
}
|
||||
}
|
||||
screen.addPreference(mirrorPref)
|
||||
screen.addPreference(altChapterListPref)
|
||||
}
|
||||
|
||||
private fun getMirrorPref(): String? = preferences.getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)
|
||||
private fun getAltChapterListPref(): Boolean = preferences.getBoolean("${ALT_CHAPTER_LIST_PREF_KEY}_$lang", ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE)
|
||||
|
||||
override val supportsLatest = true
|
||||
private val json: Json by injectLazy()
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/browse?langs=$siteLang&sort=update&page=$page")
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector(): String {
|
||||
return when (siteLang) {
|
||||
"" -> "div#series-list div.col"
|
||||
"en" -> "div#series-list div.col.no-flag"
|
||||
else -> "div#series-list div.col:has([data-lang=\"$siteLang\"])"
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
val item = element.select("a.item-cover")
|
||||
val imgurl = item.select("img").attr("abs:src")
|
||||
manga.setUrlWithoutDomain(item.attr("href"))
|
||||
manga.title = element.select("a.item-title").text().removeEntities()
|
||||
manga.thumbnail_url = imgurl
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "div#mainer nav.d-none .pagination .page-item:last-of-type:not(.disabled)"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/browse?langs=$siteLang&sort=views_a&page=$page")
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith("ID:") -> {
|
||||
val id = query.substringAfter("ID:")
|
||||
client.newCall(GET("$baseUrl/series/$id", headers)).asObservableSuccess()
|
||||
.map { response ->
|
||||
queryIDParse(response)
|
||||
}
|
||||
}
|
||||
query.isNotBlank() -> {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("word", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is LetterFilter -> {
|
||||
if (filter.state == 1) {
|
||||
url.addQueryParameter("mode", "letter")
|
||||
}
|
||||
}
|
||||
else -> { /* Do Nothing */ }
|
||||
}
|
||||
}
|
||||
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
|
||||
.map { response ->
|
||||
queryParse(response)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val url = "$baseUrl/browse".toHttpUrlOrNull()!!.newBuilder()
|
||||
var min = ""
|
||||
var max = ""
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is UtilsFilter -> {
|
||||
if (filter.state != 0) {
|
||||
val filterUrl = "$baseUrl/_utils/comic-list?type=${filter.selected}"
|
||||
return client.newCall(GET(filterUrl, headers)).asObservableSuccess()
|
||||
.map { response ->
|
||||
queryUtilsParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
is HistoryFilter -> {
|
||||
if (filter.state != 0) {
|
||||
val filterUrl = "$baseUrl/ajax.my.${filter.selected}.paging"
|
||||
return client.newCall(POST(filterUrl, headers, formBuilder().build())).asObservableSuccess()
|
||||
.map { response ->
|
||||
queryHistoryParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
is LangGroupFilter -> {
|
||||
if (filter.selected.isEmpty()) {
|
||||
url.addQueryParameter("langs", siteLang)
|
||||
} else {
|
||||
val selection = "${filter.selected.joinToString(",")},$siteLang"
|
||||
url.addQueryParameter("langs", selection)
|
||||
}
|
||||
}
|
||||
is GenreGroupFilter -> {
|
||||
with(filter) {
|
||||
url.addQueryParameter(
|
||||
"genres",
|
||||
included.joinToString(",") + "|" + excluded.joinToString(","),
|
||||
)
|
||||
}
|
||||
}
|
||||
is StatusFilter -> url.addQueryParameter("release", filter.selected)
|
||||
is SortFilter -> {
|
||||
if (filter.state != null) {
|
||||
val sort = getSortFilter()[filter.state!!.index].value
|
||||
val value = when (filter.state!!.ascending) {
|
||||
true -> "az"
|
||||
false -> "za"
|
||||
}
|
||||
url.addQueryParameter("sort", "$sort.$value")
|
||||
}
|
||||
}
|
||||
is OriginGroupFilter -> {
|
||||
if (filter.selected.isNotEmpty()) {
|
||||
url.addQueryParameter("origs", filter.selected.joinToString(","))
|
||||
}
|
||||
}
|
||||
is MinChapterTextFilter -> min = filter.state
|
||||
is MaxChapterTextFilter -> max = filter.state
|
||||
else -> { /* Do Nothing */ }
|
||||
}
|
||||
}
|
||||
url.addQueryParameter("page", page.toString())
|
||||
|
||||
if (max.isNotEmpty() or min.isNotEmpty()) {
|
||||
url.addQueryParameter("chapters", "$min-$max")
|
||||
}
|
||||
|
||||
client.newCall(GET(url.build().toString(), headers)).asObservableSuccess()
|
||||
.map { response ->
|
||||
queryParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryIDParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val infoElement = document.select("div#mainer div.container-fluid")
|
||||
val manga = SManga.create()
|
||||
manga.title = infoElement.select("h3").text().removeEntities()
|
||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||
.attr("abs:src")
|
||||
manga.url = infoElement.select("h3 a").attr("abs:href")
|
||||
return MangasPage(listOf(manga), false)
|
||||
}
|
||||
|
||||
private fun queryParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select(latestUpdatesSelector())
|
||||
.map { element -> latestUpdatesFromElement(element) }
|
||||
val nextPage = document.select(latestUpdatesNextPageSelector()).first() != null
|
||||
return MangasPage(mangas, nextPage)
|
||||
}
|
||||
|
||||
private fun queryUtilsParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("tbody > tr")
|
||||
.map { element -> searchUtilsFromElement(element) }
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
private fun queryHistoryParse(response: Response): MangasPage {
|
||||
val json = json.decodeFromString<JsonObject>(response.body.string())
|
||||
val html = json.jsonObject["html"]!!.jsonPrimitive.content
|
||||
|
||||
val document = Jsoup.parse(html, response.request.url.toString())
|
||||
val mangas = document.select(".my-history-item")
|
||||
.map { element -> searchHistoryFromElement(element) }
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
private fun searchUtilsFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
|
||||
manga.title = element.select("td a").text()
|
||||
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun searchHistoryFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.setUrlWithoutDomain(element.select(".position-relative a").attr("href"))
|
||||
manga.title = element.select(".position-relative a").text()
|
||||
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
open fun formBuilder() = FormBody.Builder().apply {
|
||||
add("_where", "browse")
|
||||
add("first", "0")
|
||||
add("limit", "0")
|
||||
add("prevPos", "null")
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException("Not used")
|
||||
override fun searchMangaSelector() = throw UnsupportedOperationException("Not used")
|
||||
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Not used")
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
if (manga.url.startsWith("http")) {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
return super.mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select("div#mainer div.container-fluid")
|
||||
val manga = SManga.create()
|
||||
val workStatus = infoElement.select("div.attr-item:contains(original work) span").text()
|
||||
val uploadStatus = infoElement.select("div.attr-item:contains(upload status) span").text()
|
||||
manga.title = infoElement.select("h3").text().removeEntities()
|
||||
manga.author = infoElement.select("div.attr-item:contains(author) span").text()
|
||||
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
|
||||
manga.status = parseStatus(workStatus, uploadStatus)
|
||||
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
|
||||
manga.description = infoElement.select("div.limit-html").text() + "\n" + infoElement.select(".episode-list > .alert-warning").text().trim()
|
||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||
.attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseStatus(workStatus: String?, uploadStatus: String?) = when {
|
||||
workStatus == null -> SManga.UNKNOWN
|
||||
workStatus.contains("Ongoing") -> SManga.ONGOING
|
||||
workStatus.contains("Cancelled") -> SManga.CANCELLED
|
||||
workStatus.contains("Hiatus") -> SManga.ON_HIATUS
|
||||
workStatus.contains("Completed") -> when {
|
||||
uploadStatus?.contains("Ongoing") == true -> SManga.PUBLISHING_FINISHED
|
||||
else -> SManga.COMPLETED
|
||||
}
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val url = client.newCall(
|
||||
GET(
|
||||
when {
|
||||
manga.url.startsWith("http") -> manga.url
|
||||
else -> "$baseUrl${manga.url}"
|
||||
},
|
||||
),
|
||||
).execute().asJsoup()
|
||||
if (getAltChapterListPref() || checkChapterLists(url)) {
|
||||
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
|
||||
return client.newCall(GET("$baseUrl/rss/series/$id.xml"))
|
||||
.asObservableSuccess()
|
||||
.map { altChapterParse(it, manga.title) }
|
||||
}
|
||||
return super.fetchChapterList(manga)
|
||||
}
|
||||
|
||||
private fun altChapterParse(response: Response, title: String): List<SChapter> {
|
||||
return Jsoup.parse(response.body.string(), response.request.url.toString(), Parser.xmlParser())
|
||||
.select("channel > item").map { item ->
|
||||
SChapter.create().apply {
|
||||
url = item.selectFirst("guid")!!.text()
|
||||
name = item.selectFirst("title")!!.text().substringAfter(title).trim()
|
||||
date_upload = SimpleDateFormat("E, dd MMM yyyy H:m:s Z", Locale.US).parse(item.selectFirst("pubDate")!!.text())?.time ?: 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkChapterLists(document: Document): Boolean {
|
||||
return document.select(".episode-list > .alert-warning").text().contains("This comic has been marked as deleted and the chapter list is not available.")
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
if (manga.url.startsWith("http")) {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
return super.chapterListRequest(manga)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.main div.p-2"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
val urlElement = element.select("a.chapt")
|
||||
val group = element.select("div.extra > a:not(.ps-3)").text()
|
||||
val time = element.select("div.extra > i.ps-3").text()
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text()
|
||||
if (group != "") {
|
||||
chapter.scanlator = group
|
||||
}
|
||||
if (time != "") {
|
||||
chapter.date_upload = parseChapterDate(time)
|
||||
}
|
||||
return chapter
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val value = date.split(' ')[0].toInt()
|
||||
|
||||
return when {
|
||||
"secs" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.SECOND, value * -1)
|
||||
}.timeInMillis
|
||||
"mins" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.MINUTE, value * -1)
|
||||
}.timeInMillis
|
||||
"hours" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.HOUR_OF_DAY, value * -1)
|
||||
}.timeInMillis
|
||||
"days" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * -1)
|
||||
}.timeInMillis
|
||||
"weeks" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * 7 * -1)
|
||||
}.timeInMillis
|
||||
"months" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.MONTH, value * -1)
|
||||
}.timeInMillis
|
||||
"years" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.YEAR, value * -1)
|
||||
}.timeInMillis
|
||||
"sec" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.SECOND, value * -1)
|
||||
}.timeInMillis
|
||||
"min" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.MINUTE, value * -1)
|
||||
}.timeInMillis
|
||||
"hour" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.HOUR_OF_DAY, value * -1)
|
||||
}.timeInMillis
|
||||
"day" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * -1)
|
||||
}.timeInMillis
|
||||
"week" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, value * 7 * -1)
|
||||
}.timeInMillis
|
||||
"month" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.MONTH, value * -1)
|
||||
}.timeInMillis
|
||||
"year" in date -> Calendar.getInstance().apply {
|
||||
add(Calendar.YEAR, value * -1)
|
||||
}.timeInMillis
|
||||
else -> {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.startsWith("http")) {
|
||||
return GET(chapter.url, headers)
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val script = document.selectFirst("script:containsData(imgHttps):containsData(batoWord):containsData(batoPass)")?.html()
|
||||
?: throw RuntimeException("Couldn't find script with image data.")
|
||||
|
||||
val imgHttpLisString = script.substringAfter("const imgHttps =").substringBefore(";").trim()
|
||||
val imgHttpLis = json.parseToJsonElement(imgHttpLisString).jsonArray.map { it.jsonPrimitive.content }
|
||||
val batoWord = script.substringAfter("const batoWord =").substringBefore(";").trim()
|
||||
val batoPass = script.substringAfter("const batoPass =").substringBefore(";").trim()
|
||||
|
||||
val evaluatedPass: String = Deobfuscator.deobfuscateJsPassword(batoPass)
|
||||
val imgAccListString = CryptoAES.decrypt(batoWord.removeSurrounding("\""), evaluatedPass)
|
||||
val imgAccList = json.parseToJsonElement(imgAccListString).jsonArray.map { it.jsonPrimitive.content }
|
||||
|
||||
return imgHttpLis.zip(imgAccList).mapIndexed { i, (imgUrl, imgAcc) ->
|
||||
Page(i, imageUrl = "$imgUrl?$imgAcc")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
LetterFilter(getLetterFilter(), 0),
|
||||
Filter.Separator(),
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
SortFilter(getSortFilter().map { it.name }.toTypedArray()),
|
||||
StatusFilter(getStatusFilter(), 0),
|
||||
GenreGroupFilter(getGenreFilter()),
|
||||
OriginGroupFilter(getOrginFilter()),
|
||||
LangGroupFilter(getLangFilter()),
|
||||
MinChapterTextFilter(),
|
||||
MaxChapterTextFilter(),
|
||||
Filter.Separator(),
|
||||
Filter.Header("NOTE: Filters below are incompatible with any other filters!"),
|
||||
Filter.Header("NOTE: Login Required!"),
|
||||
Filter.Separator(),
|
||||
UtilsFilter(getUtilsFilter(), 0),
|
||||
HistoryFilter(getHistoryFilter(), 0),
|
||||
)
|
||||
class SelectFilterOption(val name: String, val value: String)
|
||||
class CheckboxFilterOption(val value: String, name: String, default: Boolean = false) : Filter.CheckBox(name, default)
|
||||
class TriStateFilterOption(val value: String, name: String, default: Int = 0) : Filter.TriState(name, default)
|
||||
|
||||
abstract class SelectFilter(name: String, private val options: List<SelectFilterOption>, default: Int = 0) : Filter.Select<String>(name, options.map { it.name }.toTypedArray(), default) {
|
||||
val selected: String
|
||||
get() = options[state].value
|
||||
}
|
||||
|
||||
abstract class CheckboxGroupFilter(name: String, options: List<CheckboxFilterOption>) : Filter.Group<CheckboxFilterOption>(name, options) {
|
||||
val selected: List<String>
|
||||
get() = state.filter { it.state }.map { it.value }
|
||||
}
|
||||
|
||||
abstract class TriStateGroupFilter(name: String, options: List<TriStateFilterOption>) : Filter.Group<TriStateFilterOption>(name, options) {
|
||||
val included: List<String>
|
||||
get() = state.filter { it.isIncluded() }.map { it.value }
|
||||
|
||||
val excluded: List<String>
|
||||
get() = state.filter { it.isExcluded() }.map { it.value }
|
||||
}
|
||||
|
||||
abstract class TextFilter(name: String) : Filter.Text(name)
|
||||
|
||||
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort", sortables, Selection(5, false))
|
||||
class StatusFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Status", options, default)
|
||||
class OriginGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Origin", options)
|
||||
class GenreGroupFilter(options: List<TriStateFilterOption>) : TriStateGroupFilter("Genre", options)
|
||||
class MinChapterTextFilter : TextFilter("Min. Chapters")
|
||||
class MaxChapterTextFilter : TextFilter("Max. Chapters")
|
||||
class LangGroupFilter(options: List<CheckboxFilterOption>) : CheckboxGroupFilter("Languages", options)
|
||||
class LetterFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Letter matching mode (Slow)", options, default)
|
||||
class UtilsFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Utils comic list", options, default)
|
||||
class HistoryFilter(options: List<SelectFilterOption>, default: Int) : SelectFilter("Personal list", options, default)
|
||||
|
||||
private fun getLetterFilter() = listOf(
|
||||
SelectFilterOption("Disabled", "disabled"),
|
||||
SelectFilterOption("Enabled", "enabled"),
|
||||
)
|
||||
|
||||
private fun getSortFilter() = listOf(
|
||||
SelectFilterOption("Z-A", "title"),
|
||||
SelectFilterOption("Last Updated", "update"),
|
||||
SelectFilterOption("Newest Added", "create"),
|
||||
SelectFilterOption("Most Views Totally", "views_a"),
|
||||
SelectFilterOption("Most Views 365 days", "views_y"),
|
||||
SelectFilterOption("Most Views 30 days", "views_m"),
|
||||
SelectFilterOption("Most Views 7 days", "views_w"),
|
||||
SelectFilterOption("Most Views 24 hours", "views_d"),
|
||||
SelectFilterOption("Most Views 60 minutes", "views_h"),
|
||||
)
|
||||
|
||||
private fun getHistoryFilter() = listOf(
|
||||
SelectFilterOption("None", ""),
|
||||
SelectFilterOption("My History", "history"),
|
||||
SelectFilterOption("My Updates", "updates"),
|
||||
)
|
||||
|
||||
private fun getUtilsFilter() = listOf(
|
||||
SelectFilterOption("None", ""),
|
||||
SelectFilterOption("Comics: I Created", "i-created"),
|
||||
SelectFilterOption("Comics: I Modified", "i-modified"),
|
||||
SelectFilterOption("Comics: I Uploaded", "i-uploaded"),
|
||||
SelectFilterOption("Comics: Authorized to me", "i-authorized"),
|
||||
SelectFilterOption("Comics: Draft Status", "status-draft"),
|
||||
SelectFilterOption("Comics: Hidden Status", "status-hidden"),
|
||||
SelectFilterOption("Ongoing and Not updated in 30-60 days", "not-updated-30-60"),
|
||||
SelectFilterOption("Ongoing and Not updated in 60-90 days", "not-updated-60-90"),
|
||||
SelectFilterOption("Ongoing and Not updated in 90-180 days", "not-updated-90-180"),
|
||||
SelectFilterOption("Ongoing and Not updated in 180-360 days", "not-updated-180-360"),
|
||||
SelectFilterOption("Ongoing and Not updated in 360-1000 days", "not-updated-360-1000"),
|
||||
SelectFilterOption("Ongoing and Not updated more than 1000 days", "not-updated-1000"),
|
||||
)
|
||||
|
||||
private fun getStatusFilter() = listOf(
|
||||
SelectFilterOption("All", ""),
|
||||
SelectFilterOption("Pending", "pending"),
|
||||
SelectFilterOption("Ongoing", "ongoing"),
|
||||
SelectFilterOption("Completed", "completed"),
|
||||
SelectFilterOption("Hiatus", "hiatus"),
|
||||
SelectFilterOption("Cancelled", "cancelled"),
|
||||
)
|
||||
|
||||
private fun getOrginFilter() = listOf(
|
||||
// Values exported from publish.bato.to
|
||||
CheckboxFilterOption("zh", "Chinese"),
|
||||
CheckboxFilterOption("en", "English"),
|
||||
CheckboxFilterOption("ja", "Japanese"),
|
||||
CheckboxFilterOption("ko", "Korean"),
|
||||
CheckboxFilterOption("af", "Afrikaans"),
|
||||
CheckboxFilterOption("sq", "Albanian"),
|
||||
CheckboxFilterOption("am", "Amharic"),
|
||||
CheckboxFilterOption("ar", "Arabic"),
|
||||
CheckboxFilterOption("hy", "Armenian"),
|
||||
CheckboxFilterOption("az", "Azerbaijani"),
|
||||
CheckboxFilterOption("be", "Belarusian"),
|
||||
CheckboxFilterOption("bn", "Bengali"),
|
||||
CheckboxFilterOption("bs", "Bosnian"),
|
||||
CheckboxFilterOption("bg", "Bulgarian"),
|
||||
CheckboxFilterOption("my", "Burmese"),
|
||||
CheckboxFilterOption("km", "Cambodian"),
|
||||
CheckboxFilterOption("ca", "Catalan"),
|
||||
CheckboxFilterOption("ceb", "Cebuano"),
|
||||
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
|
||||
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
|
||||
CheckboxFilterOption("hr", "Croatian"),
|
||||
CheckboxFilterOption("cs", "Czech"),
|
||||
CheckboxFilterOption("da", "Danish"),
|
||||
CheckboxFilterOption("nl", "Dutch"),
|
||||
CheckboxFilterOption("en_us", "English (United States)"),
|
||||
CheckboxFilterOption("eo", "Esperanto"),
|
||||
CheckboxFilterOption("et", "Estonian"),
|
||||
CheckboxFilterOption("fo", "Faroese"),
|
||||
CheckboxFilterOption("fil", "Filipino"),
|
||||
CheckboxFilterOption("fi", "Finnish"),
|
||||
CheckboxFilterOption("fr", "French"),
|
||||
CheckboxFilterOption("ka", "Georgian"),
|
||||
CheckboxFilterOption("de", "German"),
|
||||
CheckboxFilterOption("el", "Greek"),
|
||||
CheckboxFilterOption("gn", "Guarani"),
|
||||
CheckboxFilterOption("gu", "Gujarati"),
|
||||
CheckboxFilterOption("ht", "Haitian Creole"),
|
||||
CheckboxFilterOption("ha", "Hausa"),
|
||||
CheckboxFilterOption("he", "Hebrew"),
|
||||
CheckboxFilterOption("hi", "Hindi"),
|
||||
CheckboxFilterOption("hu", "Hungarian"),
|
||||
CheckboxFilterOption("is", "Icelandic"),
|
||||
CheckboxFilterOption("ig", "Igbo"),
|
||||
CheckboxFilterOption("id", "Indonesian"),
|
||||
CheckboxFilterOption("ga", "Irish"),
|
||||
CheckboxFilterOption("it", "Italian"),
|
||||
CheckboxFilterOption("jv", "Javanese"),
|
||||
CheckboxFilterOption("kn", "Kannada"),
|
||||
CheckboxFilterOption("kk", "Kazakh"),
|
||||
CheckboxFilterOption("ku", "Kurdish"),
|
||||
CheckboxFilterOption("ky", "Kyrgyz"),
|
||||
CheckboxFilterOption("lo", "Laothian"),
|
||||
CheckboxFilterOption("lv", "Latvian"),
|
||||
CheckboxFilterOption("lt", "Lithuanian"),
|
||||
CheckboxFilterOption("lb", "Luxembourgish"),
|
||||
CheckboxFilterOption("mk", "Macedonian"),
|
||||
CheckboxFilterOption("mg", "Malagasy"),
|
||||
CheckboxFilterOption("ms", "Malay"),
|
||||
CheckboxFilterOption("ml", "Malayalam"),
|
||||
CheckboxFilterOption("mt", "Maltese"),
|
||||
CheckboxFilterOption("mi", "Maori"),
|
||||
CheckboxFilterOption("mr", "Marathi"),
|
||||
CheckboxFilterOption("mo", "Moldavian"),
|
||||
CheckboxFilterOption("mn", "Mongolian"),
|
||||
CheckboxFilterOption("ne", "Nepali"),
|
||||
CheckboxFilterOption("no", "Norwegian"),
|
||||
CheckboxFilterOption("ny", "Nyanja"),
|
||||
CheckboxFilterOption("ps", "Pashto"),
|
||||
CheckboxFilterOption("fa", "Persian"),
|
||||
CheckboxFilterOption("pl", "Polish"),
|
||||
CheckboxFilterOption("pt", "Portuguese"),
|
||||
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
|
||||
CheckboxFilterOption("ro", "Romanian"),
|
||||
CheckboxFilterOption("rm", "Romansh"),
|
||||
CheckboxFilterOption("ru", "Russian"),
|
||||
CheckboxFilterOption("sm", "Samoan"),
|
||||
CheckboxFilterOption("sr", "Serbian"),
|
||||
CheckboxFilterOption("sh", "Serbo-Croatian"),
|
||||
CheckboxFilterOption("st", "Sesotho"),
|
||||
CheckboxFilterOption("sn", "Shona"),
|
||||
CheckboxFilterOption("sd", "Sindhi"),
|
||||
CheckboxFilterOption("si", "Sinhalese"),
|
||||
CheckboxFilterOption("sk", "Slovak"),
|
||||
CheckboxFilterOption("sl", "Slovenian"),
|
||||
CheckboxFilterOption("so", "Somali"),
|
||||
CheckboxFilterOption("es", "Spanish"),
|
||||
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
|
||||
CheckboxFilterOption("sw", "Swahili"),
|
||||
CheckboxFilterOption("sv", "Swedish"),
|
||||
CheckboxFilterOption("tg", "Tajik"),
|
||||
CheckboxFilterOption("ta", "Tamil"),
|
||||
CheckboxFilterOption("th", "Thai"),
|
||||
CheckboxFilterOption("ti", "Tigrinya"),
|
||||
CheckboxFilterOption("to", "Tonga"),
|
||||
CheckboxFilterOption("tr", "Turkish"),
|
||||
CheckboxFilterOption("tk", "Turkmen"),
|
||||
CheckboxFilterOption("uk", "Ukrainian"),
|
||||
CheckboxFilterOption("ur", "Urdu"),
|
||||
CheckboxFilterOption("uz", "Uzbek"),
|
||||
CheckboxFilterOption("vi", "Vietnamese"),
|
||||
CheckboxFilterOption("yo", "Yoruba"),
|
||||
CheckboxFilterOption("zu", "Zulu"),
|
||||
CheckboxFilterOption("_t", "Other"),
|
||||
)
|
||||
|
||||
private fun getGenreFilter() = listOf(
|
||||
TriStateFilterOption("artbook", "Artbook"),
|
||||
TriStateFilterOption("cartoon", "Cartoon"),
|
||||
TriStateFilterOption("comic", "Comic"),
|
||||
TriStateFilterOption("doujinshi", "Doujinshi"),
|
||||
TriStateFilterOption("imageset", "Imageset"),
|
||||
TriStateFilterOption("manga", "Manga"),
|
||||
TriStateFilterOption("manhua", "Manhua"),
|
||||
TriStateFilterOption("manhwa", "Manhwa"),
|
||||
TriStateFilterOption("webtoon", "Webtoon"),
|
||||
TriStateFilterOption("western", "Western"),
|
||||
|
||||
TriStateFilterOption("shoujo", "Shoujo(G)"),
|
||||
TriStateFilterOption("shounen", "Shounen(B)"),
|
||||
TriStateFilterOption("josei", "Josei(W)"),
|
||||
TriStateFilterOption("seinen", "Seinen(M)"),
|
||||
TriStateFilterOption("yuri", "Yuri(GL)"),
|
||||
TriStateFilterOption("yaoi", "Yaoi(BL)"),
|
||||
TriStateFilterOption("futa", "Futa(WL)"),
|
||||
TriStateFilterOption("bara", "Bara(ML)"),
|
||||
|
||||
TriStateFilterOption("gore", "Gore"),
|
||||
TriStateFilterOption("bloody", "Bloody"),
|
||||
TriStateFilterOption("violence", "Violence"),
|
||||
TriStateFilterOption("ecchi", "Ecchi"),
|
||||
TriStateFilterOption("adult", "Adult"),
|
||||
TriStateFilterOption("mature", "Mature"),
|
||||
TriStateFilterOption("smut", "Smut"),
|
||||
TriStateFilterOption("hentai", "Hentai"),
|
||||
|
||||
TriStateFilterOption("_4_koma", "4-Koma"),
|
||||
TriStateFilterOption("action", "Action"),
|
||||
TriStateFilterOption("adaptation", "Adaptation"),
|
||||
TriStateFilterOption("adventure", "Adventure"),
|
||||
TriStateFilterOption("age_gap", "Age Gap"),
|
||||
TriStateFilterOption("aliens", "Aliens"),
|
||||
TriStateFilterOption("animals", "Animals"),
|
||||
TriStateFilterOption("anthology", "Anthology"),
|
||||
TriStateFilterOption("beasts", "Beasts"),
|
||||
TriStateFilterOption("bodyswap", "Bodyswap"),
|
||||
TriStateFilterOption("cars", "cars"),
|
||||
TriStateFilterOption("cheating_infidelity", "Cheating/Infidelity"),
|
||||
TriStateFilterOption("childhood_friends", "Childhood Friends"),
|
||||
TriStateFilterOption("college_life", "College Life"),
|
||||
TriStateFilterOption("comedy", "Comedy"),
|
||||
TriStateFilterOption("contest_winning", "Contest Winning"),
|
||||
TriStateFilterOption("cooking", "Cooking"),
|
||||
TriStateFilterOption("crime", "crime"),
|
||||
TriStateFilterOption("crossdressing", "Crossdressing"),
|
||||
TriStateFilterOption("delinquents", "Delinquents"),
|
||||
TriStateFilterOption("dementia", "Dementia"),
|
||||
TriStateFilterOption("demons", "Demons"),
|
||||
TriStateFilterOption("drama", "Drama"),
|
||||
TriStateFilterOption("dungeons", "Dungeons"),
|
||||
TriStateFilterOption("emperor_daughte", "Emperor's Daughter"),
|
||||
TriStateFilterOption("fantasy", "Fantasy"),
|
||||
TriStateFilterOption("fan_colored", "Fan-Colored"),
|
||||
TriStateFilterOption("fetish", "Fetish"),
|
||||
TriStateFilterOption("full_color", "Full Color"),
|
||||
TriStateFilterOption("game", "Game"),
|
||||
TriStateFilterOption("gender_bender", "Gender Bender"),
|
||||
TriStateFilterOption("genderswap", "Genderswap"),
|
||||
TriStateFilterOption("ghosts", "Ghosts"),
|
||||
TriStateFilterOption("gyaru", "Gyaru"),
|
||||
TriStateFilterOption("harem", "Harem"),
|
||||
TriStateFilterOption("harlequin", "Harlequin"),
|
||||
TriStateFilterOption("historical", "Historical"),
|
||||
TriStateFilterOption("horror", "Horror"),
|
||||
TriStateFilterOption("incest", "Incest"),
|
||||
TriStateFilterOption("isekai", "Isekai"),
|
||||
TriStateFilterOption("kids", "Kids"),
|
||||
TriStateFilterOption("loli", "Loli"),
|
||||
TriStateFilterOption("magic", "Magic"),
|
||||
TriStateFilterOption("magical_girls", "Magical Girls"),
|
||||
TriStateFilterOption("martial_arts", "Martial Arts"),
|
||||
TriStateFilterOption("mecha", "Mecha"),
|
||||
TriStateFilterOption("medical", "Medical"),
|
||||
TriStateFilterOption("military", "Military"),
|
||||
TriStateFilterOption("monster_girls", "Monster Girls"),
|
||||
TriStateFilterOption("monsters", "Monsters"),
|
||||
TriStateFilterOption("music", "Music"),
|
||||
TriStateFilterOption("mystery", "Mystery"),
|
||||
TriStateFilterOption("netorare", "Netorare/NTR"),
|
||||
TriStateFilterOption("ninja", "Ninja"),
|
||||
TriStateFilterOption("office_workers", "Office Workers"),
|
||||
TriStateFilterOption("omegaverse", "Omegaverse"),
|
||||
TriStateFilterOption("oneshot", "Oneshot"),
|
||||
TriStateFilterOption("parody", "parody"),
|
||||
TriStateFilterOption("philosophical", "Philosophical"),
|
||||
TriStateFilterOption("police", "Police"),
|
||||
TriStateFilterOption("post_apocalyptic", "Post-Apocalyptic"),
|
||||
TriStateFilterOption("psychological", "Psychological"),
|
||||
TriStateFilterOption("regression", "Regression"),
|
||||
TriStateFilterOption("reincarnation", "Reincarnation"),
|
||||
TriStateFilterOption("reverse_harem", "Reverse Harem"),
|
||||
TriStateFilterOption("reverse_isekai", "Reverse Isekai"),
|
||||
TriStateFilterOption("romance", "Romance"),
|
||||
TriStateFilterOption("royal_family", "Royal Family"),
|
||||
TriStateFilterOption("royalty", "Royalty"),
|
||||
TriStateFilterOption("samurai", "Samurai"),
|
||||
TriStateFilterOption("school_life", "School Life"),
|
||||
TriStateFilterOption("sci_fi", "Sci-Fi"),
|
||||
TriStateFilterOption("shota", "Shota"),
|
||||
TriStateFilterOption("shoujo_ai", "Shoujo Ai"),
|
||||
TriStateFilterOption("shounen_ai", "Shounen Ai"),
|
||||
TriStateFilterOption("showbiz", "Showbiz"),
|
||||
TriStateFilterOption("slice_of_life", "Slice of Life"),
|
||||
TriStateFilterOption("sm_bdsm", "SM/BDSM/SUB-DOM"),
|
||||
TriStateFilterOption("space", "Space"),
|
||||
TriStateFilterOption("sports", "Sports"),
|
||||
TriStateFilterOption("super_power", "Super Power"),
|
||||
TriStateFilterOption("superhero", "Superhero"),
|
||||
TriStateFilterOption("supernatural", "Supernatural"),
|
||||
TriStateFilterOption("survival", "Survival"),
|
||||
TriStateFilterOption("thriller", "Thriller"),
|
||||
TriStateFilterOption("time_travel", "Time Travel"),
|
||||
TriStateFilterOption("tower_climbing", "Tower Climbing"),
|
||||
TriStateFilterOption("traditional_games", "Traditional Games"),
|
||||
TriStateFilterOption("tragedy", "Tragedy"),
|
||||
TriStateFilterOption("transmigration", "Transmigration"),
|
||||
TriStateFilterOption("vampires", "Vampires"),
|
||||
TriStateFilterOption("villainess", "Villainess"),
|
||||
TriStateFilterOption("video_games", "Video Games"),
|
||||
TriStateFilterOption("virtual_reality", "Virtual Reality"),
|
||||
TriStateFilterOption("wuxia", "Wuxia"),
|
||||
TriStateFilterOption("xianxia", "Xianxia"),
|
||||
TriStateFilterOption("xuanhuan", "Xuanhuan"),
|
||||
TriStateFilterOption("zombies", "Zombies"),
|
||||
// Hidden Genres
|
||||
TriStateFilterOption("shotacon", "shotacon"),
|
||||
TriStateFilterOption("lolicon", "lolicon"),
|
||||
TriStateFilterOption("award_winning", "Award Winning"),
|
||||
TriStateFilterOption("youkai", "Youkai"),
|
||||
TriStateFilterOption("uncategorized", "Uncategorized"),
|
||||
)
|
||||
|
||||
private fun getLangFilter() = listOf(
|
||||
// Values exported from publish.bato.to
|
||||
CheckboxFilterOption("en", "English"),
|
||||
CheckboxFilterOption("ar", "Arabic"),
|
||||
CheckboxFilterOption("bg", "Bulgarian"),
|
||||
CheckboxFilterOption("zh", "Chinese"),
|
||||
CheckboxFilterOption("cs", "Czech"),
|
||||
CheckboxFilterOption("da", "Danish"),
|
||||
CheckboxFilterOption("nl", "Dutch"),
|
||||
CheckboxFilterOption("fil", "Filipino"),
|
||||
CheckboxFilterOption("fi", "Finnish"),
|
||||
CheckboxFilterOption("fr", "French"),
|
||||
CheckboxFilterOption("de", "German"),
|
||||
CheckboxFilterOption("el", "Greek"),
|
||||
CheckboxFilterOption("he", "Hebrew"),
|
||||
CheckboxFilterOption("hi", "Hindi"),
|
||||
CheckboxFilterOption("hu", "Hungarian"),
|
||||
CheckboxFilterOption("id", "Indonesian"),
|
||||
CheckboxFilterOption("it", "Italian"),
|
||||
CheckboxFilterOption("ja", "Japanese"),
|
||||
CheckboxFilterOption("ko", "Korean"),
|
||||
CheckboxFilterOption("ms", "Malay"),
|
||||
CheckboxFilterOption("pl", "Polish"),
|
||||
CheckboxFilterOption("pt", "Portuguese"),
|
||||
CheckboxFilterOption("pt_br", "Portuguese (Brazil)"),
|
||||
CheckboxFilterOption("ro", "Romanian"),
|
||||
CheckboxFilterOption("ru", "Russian"),
|
||||
CheckboxFilterOption("es", "Spanish"),
|
||||
CheckboxFilterOption("es_419", "Spanish (Latin America)"),
|
||||
CheckboxFilterOption("sv", "Swedish"),
|
||||
CheckboxFilterOption("th", "Thai"),
|
||||
CheckboxFilterOption("tr", "Turkish"),
|
||||
CheckboxFilterOption("uk", "Ukrainian"),
|
||||
CheckboxFilterOption("vi", "Vietnamese"),
|
||||
CheckboxFilterOption("af", "Afrikaans"),
|
||||
CheckboxFilterOption("sq", "Albanian"),
|
||||
CheckboxFilterOption("am", "Amharic"),
|
||||
CheckboxFilterOption("hy", "Armenian"),
|
||||
CheckboxFilterOption("az", "Azerbaijani"),
|
||||
CheckboxFilterOption("be", "Belarusian"),
|
||||
CheckboxFilterOption("bn", "Bengali"),
|
||||
CheckboxFilterOption("bs", "Bosnian"),
|
||||
CheckboxFilterOption("my", "Burmese"),
|
||||
CheckboxFilterOption("km", "Cambodian"),
|
||||
CheckboxFilterOption("ca", "Catalan"),
|
||||
CheckboxFilterOption("ceb", "Cebuano"),
|
||||
CheckboxFilterOption("zh_hk", "Chinese (Cantonese)"),
|
||||
CheckboxFilterOption("zh_tw", "Chinese (Traditional)"),
|
||||
CheckboxFilterOption("hr", "Croatian"),
|
||||
CheckboxFilterOption("en_us", "English (United States)"),
|
||||
CheckboxFilterOption("eo", "Esperanto"),
|
||||
CheckboxFilterOption("et", "Estonian"),
|
||||
CheckboxFilterOption("fo", "Faroese"),
|
||||
CheckboxFilterOption("ka", "Georgian"),
|
||||
CheckboxFilterOption("gn", "Guarani"),
|
||||
CheckboxFilterOption("gu", "Gujarati"),
|
||||
CheckboxFilterOption("ht", "Haitian Creole"),
|
||||
CheckboxFilterOption("ha", "Hausa"),
|
||||
CheckboxFilterOption("is", "Icelandic"),
|
||||
CheckboxFilterOption("ig", "Igbo"),
|
||||
CheckboxFilterOption("ga", "Irish"),
|
||||
CheckboxFilterOption("jv", "Javanese"),
|
||||
CheckboxFilterOption("kn", "Kannada"),
|
||||
CheckboxFilterOption("kk", "Kazakh"),
|
||||
CheckboxFilterOption("ku", "Kurdish"),
|
||||
CheckboxFilterOption("ky", "Kyrgyz"),
|
||||
CheckboxFilterOption("lo", "Laothian"),
|
||||
CheckboxFilterOption("lv", "Latvian"),
|
||||
CheckboxFilterOption("lt", "Lithuanian"),
|
||||
CheckboxFilterOption("lb", "Luxembourgish"),
|
||||
CheckboxFilterOption("mk", "Macedonian"),
|
||||
CheckboxFilterOption("mg", "Malagasy"),
|
||||
CheckboxFilterOption("ml", "Malayalam"),
|
||||
CheckboxFilterOption("mt", "Maltese"),
|
||||
CheckboxFilterOption("mi", "Maori"),
|
||||
CheckboxFilterOption("mr", "Marathi"),
|
||||
CheckboxFilterOption("mo", "Moldavian"),
|
||||
CheckboxFilterOption("mn", "Mongolian"),
|
||||
CheckboxFilterOption("ne", "Nepali"),
|
||||
CheckboxFilterOption("no", "Norwegian"),
|
||||
CheckboxFilterOption("ny", "Nyanja"),
|
||||
CheckboxFilterOption("ps", "Pashto"),
|
||||
CheckboxFilterOption("fa", "Persian"),
|
||||
CheckboxFilterOption("rm", "Romansh"),
|
||||
CheckboxFilterOption("sm", "Samoan"),
|
||||
CheckboxFilterOption("sr", "Serbian"),
|
||||
CheckboxFilterOption("sh", "Serbo-Croatian"),
|
||||
CheckboxFilterOption("st", "Sesotho"),
|
||||
CheckboxFilterOption("sn", "Shona"),
|
||||
CheckboxFilterOption("sd", "Sindhi"),
|
||||
CheckboxFilterOption("si", "Sinhalese"),
|
||||
CheckboxFilterOption("sk", "Slovak"),
|
||||
CheckboxFilterOption("sl", "Slovenian"),
|
||||
CheckboxFilterOption("so", "Somali"),
|
||||
CheckboxFilterOption("sw", "Swahili"),
|
||||
CheckboxFilterOption("tg", "Tajik"),
|
||||
CheckboxFilterOption("ta", "Tamil"),
|
||||
CheckboxFilterOption("ti", "Tigrinya"),
|
||||
CheckboxFilterOption("to", "Tonga"),
|
||||
CheckboxFilterOption("tk", "Turkmen"),
|
||||
CheckboxFilterOption("ur", "Urdu"),
|
||||
CheckboxFilterOption("uz", "Uzbek"),
|
||||
CheckboxFilterOption("yo", "Yoruba"),
|
||||
CheckboxFilterOption("zu", "Zulu"),
|
||||
CheckboxFilterOption("_t", "Other"),
|
||||
// Lang options from bato.to brows not in publish.bato.to
|
||||
CheckboxFilterOption("eu", "Basque"),
|
||||
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
|
||||
).filterNot { it.value == siteLang }
|
||||
|
||||
companion object {
|
||||
private const val MIRROR_PREF_KEY = "MIRROR"
|
||||
private const val MIRROR_PREF_TITLE = "Mirror"
|
||||
private val MIRROR_PREF_ENTRIES = arrayOf(
|
||||
"bato.to",
|
||||
"batocomic.com",
|
||||
"batocomic.net",
|
||||
"batocomic.org",
|
||||
"batotoo.com",
|
||||
"batotwo.com",
|
||||
"battwo.com",
|
||||
"comiko.net",
|
||||
"comiko.org",
|
||||
"mangatoto.com",
|
||||
"mangatoto.net",
|
||||
"mangatoto.org",
|
||||
"readtoto.com",
|
||||
"readtoto.net",
|
||||
"readtoto.org",
|
||||
"dto.to",
|
||||
"hto.to",
|
||||
"mto.to",
|
||||
"wto.to",
|
||||
"xbato.com",
|
||||
"xbato.net",
|
||||
"xbato.org",
|
||||
"zbato.com",
|
||||
"zbato.net",
|
||||
"zbato.org",
|
||||
)
|
||||
private val MIRROR_PREF_ENTRY_VALUES = MIRROR_PREF_ENTRIES.map { "https://$it" }.toTypedArray()
|
||||
private val MIRROR_PREF_DEFAULT_VALUE = MIRROR_PREF_ENTRY_VALUES[0]
|
||||
|
||||
private const val ALT_CHAPTER_LIST_PREF_KEY = "ALT_CHAPTER_LIST"
|
||||
private const val ALT_CHAPTER_LIST_PREF_TITLE = "Alternative Chapter List"
|
||||
private const val ALT_CHAPTER_LIST_PREF_SUMMARY = "If checked, uses an alternate chapter list"
|
||||
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class BatoToFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = languages.map { BatoTo(it.lang, it.siteLang) }
|
||||
}
|
||||
|
||||
class LanguageOption(val lang: String, val siteLang: String = lang)
|
||||
private val languages = listOf(
|
||||
LanguageOption("all", ""),
|
||||
// Lang options from publish.bato.to
|
||||
LanguageOption("en"),
|
||||
LanguageOption("ar"),
|
||||
LanguageOption("bg"),
|
||||
LanguageOption("zh"),
|
||||
LanguageOption("cs"),
|
||||
LanguageOption("da"),
|
||||
LanguageOption("nl"),
|
||||
LanguageOption("fil"),
|
||||
LanguageOption("fi"),
|
||||
LanguageOption("fr"),
|
||||
LanguageOption("de"),
|
||||
LanguageOption("el"),
|
||||
LanguageOption("he"),
|
||||
LanguageOption("hi"),
|
||||
LanguageOption("hu"),
|
||||
LanguageOption("id"),
|
||||
LanguageOption("it"),
|
||||
LanguageOption("ja"),
|
||||
LanguageOption("ko"),
|
||||
LanguageOption("ms"),
|
||||
LanguageOption("pl"),
|
||||
LanguageOption("pt"),
|
||||
LanguageOption("pt-BR", "pt_br"),
|
||||
LanguageOption("ro"),
|
||||
LanguageOption("ru"),
|
||||
LanguageOption("es"),
|
||||
LanguageOption("es-419", "es_419"),
|
||||
LanguageOption("sv"),
|
||||
LanguageOption("th"),
|
||||
LanguageOption("tr"),
|
||||
LanguageOption("uk"),
|
||||
LanguageOption("vi"),
|
||||
LanguageOption("af"),
|
||||
LanguageOption("sq"),
|
||||
LanguageOption("am"),
|
||||
LanguageOption("hy"),
|
||||
LanguageOption("az"),
|
||||
LanguageOption("be"),
|
||||
LanguageOption("bn"),
|
||||
LanguageOption("bs"),
|
||||
LanguageOption("my"),
|
||||
LanguageOption("km"),
|
||||
LanguageOption("ca"),
|
||||
LanguageOption("ceb"),
|
||||
LanguageOption("zh-Hans", "zh_hk"),
|
||||
LanguageOption("zh-Hant", "zh_tw"),
|
||||
LanguageOption("hr"),
|
||||
LanguageOption("en-US", "en_us"),
|
||||
LanguageOption("eo"),
|
||||
LanguageOption("et"),
|
||||
LanguageOption("fo"),
|
||||
LanguageOption("ka"),
|
||||
LanguageOption("gn"),
|
||||
LanguageOption("gu"),
|
||||
LanguageOption("ht"),
|
||||
LanguageOption("ha"),
|
||||
LanguageOption("is"),
|
||||
LanguageOption("ig"),
|
||||
LanguageOption("ga"),
|
||||
LanguageOption("jv"),
|
||||
LanguageOption("kn"),
|
||||
LanguageOption("kk"),
|
||||
LanguageOption("ku"),
|
||||
LanguageOption("ky"),
|
||||
LanguageOption("lo"),
|
||||
LanguageOption("lv"),
|
||||
LanguageOption("lt"),
|
||||
LanguageOption("lb"),
|
||||
LanguageOption("mk"),
|
||||
LanguageOption("mg"),
|
||||
LanguageOption("ml"),
|
||||
LanguageOption("mt"),
|
||||
LanguageOption("mi"),
|
||||
LanguageOption("mr"),
|
||||
LanguageOption("mo", "ro-MD"),
|
||||
LanguageOption("mn"),
|
||||
LanguageOption("ne"),
|
||||
LanguageOption("no"),
|
||||
LanguageOption("ny"),
|
||||
LanguageOption("ps"),
|
||||
LanguageOption("fa"),
|
||||
LanguageOption("rm"),
|
||||
LanguageOption("sm"),
|
||||
LanguageOption("sr"),
|
||||
LanguageOption("sh"),
|
||||
LanguageOption("st"),
|
||||
LanguageOption("sn"),
|
||||
LanguageOption("sd"),
|
||||
LanguageOption("si"),
|
||||
LanguageOption("sk"),
|
||||
LanguageOption("sl"),
|
||||
LanguageOption("so"),
|
||||
LanguageOption("sw"),
|
||||
LanguageOption("tg"),
|
||||
LanguageOption("ta"),
|
||||
LanguageOption("ti"),
|
||||
LanguageOption("to"),
|
||||
LanguageOption("tk"),
|
||||
LanguageOption("ur"),
|
||||
LanguageOption("uz"),
|
||||
LanguageOption("yo"),
|
||||
LanguageOption("zu"),
|
||||
LanguageOption("other", "_t"),
|
||||
// Lang options from bato.to brows not in publish.bato.to
|
||||
LanguageOption("eu"),
|
||||
LanguageOption("pt-PT", "pt_pt"),
|
||||
// Lang options that got removed
|
||||
// Pair("xh", "xh"),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
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 BatoToUrlActivity : 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 = fromBatoTo(pathSegments)
|
||||
|
||||
if (query == null) {
|
||||
Log.e("BatoToUrlActivity", "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("BatoToUrlActivity", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun fromBatoTo(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 2) {
|
||||
val id = pathSegments[1]
|
||||
"ID:$id"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/all/buondua/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
12
src/all/buondua/build.gradle
Normal file
@@ -0,0 +1,12 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Buon Dua'
|
||||
pkgNameSuffix = 'all.buondua'
|
||||
extClass = '.BuonDua'
|
||||
extVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/buondua/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/all/buondua/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/buondua/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src/all/buondua/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/all/buondua/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/all/buondua/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,117 @@
|
||||
package eu.kanade.tachiyomi.extension.all.buondua
|
||||
|
||||
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.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BuonDua() : ParsedHttpSource() {
|
||||
override val baseUrl = "https://buondua.com"
|
||||
override val lang = "all"
|
||||
override val name = "Buon Dua"
|
||||
override val supportsLatest = true
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||
manga.title = element.select(".item-content .item-link").text()
|
||||
manga.setUrlWithoutDomain(element.select(".item-content .item-link").attr("abs:href"))
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = ".pagination-next:not([disabled])"
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/?start=${20 * (page - 1)}")
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = ".blog > div"
|
||||
|
||||
// Popular
|
||||
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/hot?start=${20 * (page - 1)}")
|
||||
}
|
||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val tagFilter = filters.findInstance<TagFilter>()!!
|
||||
return when {
|
||||
query.isNotEmpty() -> GET("$baseUrl/?search=$query&start=${20 * (page - 1)}")
|
||||
tagFilter.state.isNotEmpty() -> GET("$baseUrl/tag/${tagFilter.state}&start=${20 * (page - 1)}")
|
||||
else -> popularMangaRequest(page)
|
||||
}
|
||||
}
|
||||
override fun searchMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = document.select(".article-header").text()
|
||||
manga.description = document.select(".article-info > strong").text().trim()
|
||||
val genres = mutableListOf<String>()
|
||||
document.select(".article-tags").first()!!.select(".tags > .tag").forEach {
|
||||
genres.add(it.text().substringAfter("#"))
|
||||
}
|
||||
manga.genre = genres.joinToString(", ")
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(element.select(".is-current").first()!!.attr("abs:href"))
|
||||
chapter.chapter_number = 0F
|
||||
chapter.name = element.select(".article-header").text()
|
||||
chapter.date_upload = SimpleDateFormat("H:m DD-MM-yyyy", Locale.US).parse(element.select(".article-info > small").text())?.time ?: 0L
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "html"
|
||||
|
||||
// Pages
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val numpages = document.selectFirst(".pagination-list")!!.select(".pagination-link")
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
numpages.forEachIndexed { index, page ->
|
||||
val doc = when (index) {
|
||||
0 -> document
|
||||
else -> client.newCall(GET(page.attr("abs:href"))).execute().asJsoup()
|
||||
}
|
||||
doc.select(".article-fulltext img").forEach {
|
||||
val itUrl = it.attr("abs:src")
|
||||
pages.add(Page(pages.size, "", itUrl))
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Filters
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
TagFilter(),
|
||||
)
|
||||
|
||||
class TagFilter : Filter.Text("Tag ID")
|
||||
|
||||
private inline fun <reified T> Iterable<*>.findInstance() = find { it is T } as? T
|
||||
}
|
||||
2
src/all/comicfury/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
||||
17
src/all/comicfury/build.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Comic Fury'
|
||||
pkgNameSuffix = 'all.comicfury'
|
||||
extClass = '.ComicFuryFactory'
|
||||
extVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib-textinterceptor'))
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/comicfury/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/all/comicfury/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/all/comicfury/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/all/comicfury/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src/all/comicfury/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/all/comicfury/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,291 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicfury
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor
|
||||
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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 okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class ComicFury(
|
||||
override val lang: String,
|
||||
private val siteLang: String = lang, // override lang string used in MangaSearch
|
||||
private val extraName: String = "",
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
override val baseUrl: String = "https://comicfury.com"
|
||||
override val name: String = "Comic Fury$extraName" //Used for No Text
|
||||
override val supportsLatest: Boolean = true
|
||||
private val dateFormat = SimpleDateFormat("dd MMM yyyy hh:mm aa", Locale.US)
|
||||
private val dateFormatSlim = SimpleDateFormat("dd MMM yyyy", Locale.US)
|
||||
|
||||
override val client = super.client.newBuilder().addInterceptor(TextInterceptor()).build()
|
||||
|
||||
/**
|
||||
* Archive is on a separate page from manga info
|
||||
*/
|
||||
override fun chapterListRequest(manga: SManga): Request =
|
||||
GET("$baseUrl/read/${manga.url.substringAfter("?url=")}/archive")
|
||||
|
||||
/**
|
||||
* Open Archive Url instead of the details page
|
||||
* Helps with getting past the nfsw pages
|
||||
*/
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl/read/" + manga.url.substringAfter("?url=") + "/archive"
|
||||
}
|
||||
|
||||
/**
|
||||
* There are two different ways chapters are setup
|
||||
* First Way if (true)
|
||||
* Manga -> Chapter -> Comic -> Pages
|
||||
* The Second Way if (false)
|
||||
* Manga -> Comic -> Pages
|
||||
*
|
||||
* Importantly the Chapter And Comic Pages can be easy distinguished
|
||||
* by the name of the list elements in this case archive-chapter/archive-comic
|
||||
*
|
||||
* For Manga that doesn't have "chapters" skip the loop. Including All Sub-Comics of Chapters
|
||||
*
|
||||
* Put the chapter name into scanlator so read can know what chapter it is.
|
||||
*
|
||||
* Chapter Number is handled as Chapter dot Comic. Ex. Chapter 6, Comic 4: chapter_number = 6.4
|
||||
*
|
||||
*/
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val jsp = response.asJsoup()
|
||||
if (jsp.selectFirst("div.archive-chapter") != null) {
|
||||
val chapters: MutableList<SChapter> = arrayListOf()
|
||||
for (chapter in jsp.select("div.archive-chapter").parents().reversed()) {
|
||||
val name = chapter.text()
|
||||
chapters.addAll(
|
||||
client.newCall(
|
||||
GET("$baseUrl${chapter.attr("href")}"),
|
||||
).execute()
|
||||
.use { chapterListParse(it) }
|
||||
.mapIndexed { i, it ->
|
||||
it.apply {
|
||||
scanlator = name
|
||||
chapter_number += i
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return chapters
|
||||
} else {
|
||||
return jsp.select("div.archive-comic").mapIndexed { i, it ->
|
||||
SChapter.create().apply {
|
||||
url = it.parent()!!.attr("href")
|
||||
name = it.child(0).ownText()
|
||||
date_upload = it.child(1).ownText().toDate()
|
||||
chapter_number = "0.$i".toFloat()
|
||||
}
|
||||
}.toList().reversed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val jsp = response.asJsoup()
|
||||
val pages: MutableList<Page> = arrayListOf()
|
||||
val comic = jsp.selectFirst("div.is--comic-page")
|
||||
for (child in comic!!.select("div.is--image-segment div img")) {
|
||||
pages.add(
|
||||
Page(
|
||||
pages.size,
|
||||
response.request.url.toString(),
|
||||
child.attr("src"),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (showAuthorsNotesPref()) {
|
||||
for (child in comic.select("div.is--author-notes div.is--comment-box").withIndex()) {
|
||||
pages.add(
|
||||
Page(
|
||||
pages.size,
|
||||
response.request.url.toString(),
|
||||
TextInterceptorHelper.createUrl(
|
||||
jsp.selectFirst("a.is--comment-author")?.ownText()
|
||||
?: "Error No Author For Comment Found",
|
||||
jsp.selectFirst("div.is--comment-content")?.html()
|
||||
?: "Error No Comment Content Found",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
* Author name joining maybe redundant.
|
||||
*
|
||||
* Manga Status is available but not currently implemented.
|
||||
*/
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val jsp = response.asJsoup()
|
||||
val desDiv = jsp.selectFirst("div.description-tags")
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(response.request.url.toString())
|
||||
description = desDiv?.parent()?.ownText()
|
||||
genre = desDiv?.children()?.eachText()?.joinToString(", ")
|
||||
author = jsp.select("a.authorname").eachText().joinToString(", ")
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val jsp = response.asJsoup()
|
||||
val list: MutableList<SManga> = arrayListOf()
|
||||
for (result in jsp.select("div.webcomic-result")) {
|
||||
list.add(
|
||||
SManga.create().apply {
|
||||
url = result.selectFirst("div.webcomic-result-avatar a")!!.attr("href")
|
||||
title = result.selectFirst("div.webcomic-result-title")!!.attr("title")
|
||||
thumbnail_url = result.selectFirst("div.webcomic-result-avatar a img")!!.absUrl("src")
|
||||
},
|
||||
)
|
||||
}
|
||||
return MangasPage(list, (jsp.selectFirst("div.search-next-page") != null))
|
||||
}
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val req: HttpUrl.Builder = "$baseUrl/search.php".toHttpUrl().newBuilder()
|
||||
req.addQueryParameter("query", query)
|
||||
req.addQueryParameter("page", page.toString())
|
||||
req.addQueryParameter("language", siteLang)
|
||||
filters.forEach {
|
||||
when (it) {
|
||||
is TagsFilter -> req.addEncodedQueryParameter(
|
||||
"tags",
|
||||
it.state.replace(", ", ","),
|
||||
)
|
||||
is SortFilter -> req.addQueryParameter("sort", it.state.toString())
|
||||
is CompletedComicFilter -> req.addQueryParameter(
|
||||
"completed",
|
||||
it.state.toInt().toString(),
|
||||
)
|
||||
is LastUpdatedFilter -> req.addQueryParameter(
|
||||
"lastupdate",
|
||||
it.state.toString(),
|
||||
)
|
||||
is ViolenceFilter -> req.addQueryParameter("fv", it.state.toString())
|
||||
is NudityFilter -> req.addQueryParameter("fn", it.state.toString())
|
||||
is StrongLangFilter -> req.addQueryParameter("fl", it.state.toString())
|
||||
is SexualFilter -> req.addQueryParameter("fs", it.state.toString())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return Request.Builder().url(req.build()).build()
|
||||
}
|
||||
|
||||
// START OF AUTHOR NOTES //
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
companion object {
|
||||
private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes"
|
||||
}
|
||||
private fun showAuthorsNotesPref() =
|
||||
preferences.getBoolean(SHOW_AUTHORS_NOTES_KEY, false)
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val authorsNotesPref = SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_AUTHORS_NOTES_KEY; title = "Show author's notes"
|
||||
summary = "Enable to see the author's notes at the end of chapters (if they're there)."
|
||||
setDefaultValue(false)
|
||||
}
|
||||
screen.addPreference(authorsNotesPref)
|
||||
}
|
||||
// END OF AUTHOR NOTES //
|
||||
|
||||
// START OF FILTERS //
|
||||
override fun getFilterList(): FilterList = getFilterList(0)
|
||||
private fun getFilterList(sortIndex: Int): FilterList = FilterList(
|
||||
TagsFilter(),
|
||||
Filter.Separator(),
|
||||
SortFilter(sortIndex),
|
||||
Filter.Separator(),
|
||||
LastUpdatedFilter(),
|
||||
CompletedComicFilter(),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Flags"),
|
||||
ViolenceFilter(),
|
||||
NudityFilter(),
|
||||
StrongLangFilter(),
|
||||
SexualFilter(),
|
||||
)
|
||||
|
||||
internal class SortFilter(index: Int) : Filter.Select<String>(
|
||||
"Sort By",
|
||||
arrayOf("Relevance", "Popularity", "Last Update"),
|
||||
index,
|
||||
)
|
||||
internal class CompletedComicFilter : Filter.CheckBox("Comic Completed", false)
|
||||
internal class LastUpdatedFilter : Filter.Select<String>(
|
||||
"Last Updated",
|
||||
arrayOf("All Time", "This Week", "This Month", "This Year", "Completed Only"),
|
||||
0,
|
||||
)
|
||||
internal class ViolenceFilter : Filter.Select<String>(
|
||||
"Violence",
|
||||
arrayOf("None / Minimal", "Violent Content", "Gore / Graphic"),
|
||||
2,
|
||||
)
|
||||
internal class NudityFilter : Filter.Select<String>(
|
||||
"Frontal Nudity",
|
||||
arrayOf("None", "Occasional", "Frequent"),
|
||||
2,
|
||||
)
|
||||
internal class StrongLangFilter : Filter.Select<String>(
|
||||
"Strong Language",
|
||||
arrayOf("None", "Occasional", "Frequent"),
|
||||
2,
|
||||
)
|
||||
internal class SexualFilter : Filter.Select<String>(
|
||||
"Sexual Content",
|
||||
arrayOf("No Sexual Content", "Sexual Situations", "Strong Sexual Themes"),
|
||||
2,
|
||||
)
|
||||
internal class TagsFilter : Filter.Text("Tags")
|
||||
|
||||
// END OF FILTERS //
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
searchMangaRequest(page, "", getFilterList(1))
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
searchMangaRequest(page, "", getFilterList(2))
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun imageUrlParse(response: Response): String =
|
||||
throw UnsupportedOperationException("Not Used")
|
||||
|
||||
private fun String.toDate(): Long {
|
||||
val ret = this.replace("st", "")
|
||||
.replace("nd", "")
|
||||
.replace("rd", "")
|
||||
.replace("th", "")
|
||||
.replace(",", "")
|
||||
return dateFormat.parse(ret)?.time ?: dateFormatSlim.parse(ret)!!.time
|
||||
}
|
||||
|
||||
private fun Boolean.toInt(): Int = if (this) { 0 } else { 1 }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicfury
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class ComicFuryFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
ComicFury("all"),
|
||||
ComicFury("en"),
|
||||
ComicFury("es"),
|
||||
ComicFury("pt-BR", "pt"),
|
||||
ComicFury("de"),
|
||||
ComicFury("fr"),
|
||||
ComicFury("it"),
|
||||
ComicFury("pl"),
|
||||
ComicFury("ja"),
|
||||
ComicFury("zh"),
|
||||
ComicFury("ru"),
|
||||
ComicFury("fi"),
|
||||
ComicFury("other"),
|
||||
ComicFury("other", "notext", " (No Text)"),
|
||||
)
|
||||
}
|
||||
26
src/all/comickfun/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.comickfun.ComickFunUrlActivity"
|
||||
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:scheme="https" />
|
||||
<data android:host="comick.cc" />
|
||||
<data android:host="comick.ink" />
|
||||
<data android:host="comick.app" />
|
||||
<data android:host="comick.fun" />
|
||||
<data android:pathPattern="/comic/.*/..*" />
|
||||
<data android:pathPattern="/comic/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
13
src/all/comickfun/assets/i18n/messages_en.properties
Normal file
@@ -0,0 +1,13 @@
|
||||
ignored_groups_title=Ignored Groups
|
||||
ignored_groups_summary=Chapters from these groups won't be shown.\nOne group name per line (case-insensitive)
|
||||
include_tags_title=Include Tags
|
||||
include_tags_on=More specific, but might contain spoilers!
|
||||
include_tags_off=Only the broader genres
|
||||
update_cover_title=Update Covers
|
||||
update_cover_on=Keep cover updated
|
||||
update_cover_off=Prefer first cover
|
||||
score_position_title=Score Position in the Description
|
||||
score_position_top=Top
|
||||
score_position_middle=Middle
|
||||
score_position_bottom=Bottom
|
||||
score_position_none=Hide Score
|
||||
13
src/all/comickfun/assets/i18n/messages_pt_br.properties
Normal file
@@ -0,0 +1,13 @@
|
||||
ignored_groups_title=Grupos Ignorados
|
||||
ignored_groups_summary=Capítulos desses grupos não aparecerão.\nUm grupo por linha
|
||||
include_tags_title=Incluir Tags
|
||||
include_tags_on=Mais detalhadas, mas podem conter spoilers
|
||||
include_tags_off=Apenas os gêneros básicos
|
||||
update_cover_title=Atualizar Capas
|
||||
update_cover_on=Manter capas atualizadas
|
||||
update_cover_off=Usar apenas a primeira capa
|
||||
score_position_title=Posição da Nota na Descrição
|
||||
score_position_top=Topo
|
||||
score_position_middle=Meio
|
||||
score_position_bottom=Final
|
||||
score_position_none=Sem Nota
|
||||
17
src/all/comickfun/build.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Comick'
|
||||
pkgNameSuffix = 'all.comickfun'
|
||||
extClass = '.ComickFunFactory'
|
||||
extVersionCode = 41
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib-i18n"))
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/comickfun/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src/all/comickfun/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/all/comickfun/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/all/comickfun/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/all/comickfun/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/all/comickfun/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,497 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||
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.HttpSource
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.min
|
||||
|
||||
abstract class ComickFun(
|
||||
override val lang: String,
|
||||
private val comickFunLang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
|
||||
override val name = "Comick"
|
||||
|
||||
override val baseUrl = "https://comick.cc"
|
||||
|
||||
private val apiUrl = "https://api.comick.fun"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = true
|
||||
}
|
||||
|
||||
private lateinit var searchResponse: List<SearchManga>
|
||||
|
||||
private val intl by lazy {
|
||||
Intl(
|
||||
language = lang,
|
||||
baseLanguage = "en",
|
||||
availableLanguages = setOf("en", "pt-BR"),
|
||||
classLoader = this::class.java.classLoader!!,
|
||||
)
|
||||
}
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = IGNORED_GROUPS_PREF
|
||||
title = intl["ignored_groups_title"]
|
||||
summary = intl["ignored_groups_summary"]
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit()
|
||||
.putString(IGNORED_GROUPS_PREF, newValue.toString())
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = INCLUDE_MU_TAGS_PREF
|
||||
title = intl["include_tags_title"]
|
||||
summaryOn = intl["include_tags_on"]
|
||||
summaryOff = intl["include_tags_off"]
|
||||
setDefaultValue(INCLUDE_MU_TAGS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit()
|
||||
.putBoolean(INCLUDE_MU_TAGS_PREF, newValue as Boolean)
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = FIRST_COVER_PREF
|
||||
title = intl["update_cover_title"]
|
||||
summaryOff = intl["update_cover_off"]
|
||||
summaryOn = intl["update_cover_on"]
|
||||
setDefaultValue(FIRST_COVER_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit()
|
||||
.putBoolean(FIRST_COVER_PREF, newValue as Boolean)
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = SCORE_POSITION_PREF
|
||||
title = intl["score_position_title"]
|
||||
summary = "%s"
|
||||
entries = arrayOf(
|
||||
intl["score_position_top"],
|
||||
intl["score_position_middle"],
|
||||
intl["score_position_bottom"],
|
||||
intl["score_position_none"],
|
||||
)
|
||||
entryValues = arrayOf(SCORE_POSITION_DEFAULT, "middle", "bottom", "none")
|
||||
setDefaultValue(SCORE_POSITION_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
|
||||
preferences.edit()
|
||||
.putString(SCORE_POSITION_PREF, entry)
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private val SharedPreferences.ignoredGroups: Set<String>
|
||||
get() = getString(IGNORED_GROUPS_PREF, "")
|
||||
?.lowercase()
|
||||
?.split("\n")
|
||||
?.map(String::trim)
|
||||
?.filter(String::isNotEmpty)
|
||||
?.sorted()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
|
||||
private val SharedPreferences.includeMuTags: Boolean
|
||||
get() = getBoolean(INCLUDE_MU_TAGS_PREF, INCLUDE_MU_TAGS_DEFAULT)
|
||||
|
||||
private val SharedPreferences.updateCover: Boolean
|
||||
get() = getBoolean(FIRST_COVER_PREF, FIRST_COVER_DEFAULT)
|
||||
|
||||
private val SharedPreferences.scorePosition: String
|
||||
get() = getString(SCORE_POSITION_PREF, SCORE_POSITION_DEFAULT) ?: SCORE_POSITION_DEFAULT
|
||||
|
||||
init {
|
||||
preferences.newLineIgnoredGroups()
|
||||
}
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("Referer", "$baseUrl/")
|
||||
add("User-Agent", "Tachiyomi ${System.getProperty("http.agent")}")
|
||||
}
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.addInterceptor(::thumbnailIntercept)
|
||||
.rateLimit(3, 1)
|
||||
.build()
|
||||
|
||||
/** Popular Manga **/
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$apiUrl/v1.0/search?sort=follow&limit=$limit&page=$page&tachiyomi=true"
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<List<SearchManga>>()
|
||||
return MangasPage(
|
||||
result.map(SearchManga::toSManga),
|
||||
hasNextPage = result.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
/** Latest Manga **/
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/v1.0/search?sort=uploaded&limit=$limit&page=$page&tachiyomi=true"
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
/** Manga Search **/
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return if (query.startsWith(SLUG_SEARCH_PREFIX)) {
|
||||
// url deep link
|
||||
val slugOrHid = query.substringAfter(SLUG_SEARCH_PREFIX)
|
||||
val manga = SManga.create().apply { this.url = "/comic/$slugOrHid#" }
|
||||
fetchMangaDetails(manga).map {
|
||||
MangasPage(listOf(it), false)
|
||||
}
|
||||
} else if (query.isEmpty()) {
|
||||
// regular filtering without text search
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map(::searchMangaParse)
|
||||
} else {
|
||||
// text search, no pagination in api
|
||||
if (page == 1) {
|
||||
client.newCall(querySearchRequest(query))
|
||||
.asObservableSuccess()
|
||||
.map(::querySearchParse)
|
||||
} else {
|
||||
Observable.just(paginatedSearchPage(page))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun querySearchRequest(query: String): Request {
|
||||
val url = "$apiUrl/v1.0/search?limit=300&page=1&tachiyomi=true"
|
||||
.toHttpUrl().newBuilder()
|
||||
.addQueryParameter("q", query.trim())
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
private fun querySearchParse(response: Response): MangasPage {
|
||||
searchResponse = response.parseAs()
|
||||
|
||||
return paginatedSearchPage(1)
|
||||
}
|
||||
|
||||
private fun paginatedSearchPage(page: Int): MangasPage {
|
||||
val end = min(page * limit, searchResponse.size)
|
||||
val entries = searchResponse.subList((page - 1) * limit, end)
|
||||
.map(SearchManga::toSManga)
|
||||
return MangasPage(entries, end < searchResponse.size)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$apiUrl/v1.0/search".toHttpUrl().newBuilder().apply {
|
||||
filters.forEach { it ->
|
||||
when (it) {
|
||||
is CompletedFilter -> {
|
||||
if (it.state) {
|
||||
addQueryParameter("completed", "true")
|
||||
}
|
||||
}
|
||||
|
||||
is GenreFilter -> {
|
||||
it.state.filter { it.isIncluded() }.forEach {
|
||||
addQueryParameter("genres", it.value)
|
||||
}
|
||||
|
||||
it.state.filter { it.isExcluded() }.forEach {
|
||||
addQueryParameter("excludes", it.value)
|
||||
}
|
||||
}
|
||||
|
||||
is DemographicFilter -> {
|
||||
it.state.filter { it.isIncluded() }.forEach {
|
||||
addQueryParameter("demographic", it.value)
|
||||
}
|
||||
}
|
||||
|
||||
is TypeFilter -> {
|
||||
it.state.filter { it.state }.forEach {
|
||||
addQueryParameter("country", it.value)
|
||||
}
|
||||
}
|
||||
|
||||
is SortFilter -> {
|
||||
addQueryParameter("sort", it.getValue())
|
||||
}
|
||||
|
||||
is StatusFilter -> {
|
||||
if (it.state > 0) {
|
||||
addQueryParameter("status", it.getValue())
|
||||
}
|
||||
}
|
||||
|
||||
is CreatedAtFilter -> {
|
||||
if (it.state > 0) {
|
||||
addQueryParameter("time", it.getValue())
|
||||
}
|
||||
}
|
||||
|
||||
is MinimumFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("minimum", it.state)
|
||||
}
|
||||
}
|
||||
|
||||
is FromYearFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("from", it.state)
|
||||
}
|
||||
}
|
||||
|
||||
is ToYearFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("to", it.state)
|
||||
}
|
||||
}
|
||||
|
||||
is TagFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
it.state.split(",").forEach {
|
||||
addQueryParameter("tags", it.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
addQueryParameter("limit", "$limit")
|
||||
addQueryParameter("page", "$page")
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
/** Manga Details **/
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
// Migration from slug based urls to hid based ones
|
||||
if (!manga.url.endsWith("#")) {
|
||||
throw Exception("Migrate from Comick to Comick")
|
||||
}
|
||||
|
||||
val mangaUrl = manga.url.removeSuffix("#")
|
||||
return GET("$apiUrl$mangaUrl?tachiyomi=true", headers)
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mangaDetailsParse(response, manga).apply { initialized = true }
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga =
|
||||
mangaDetailsParse(response, SManga.create())
|
||||
|
||||
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
|
||||
val mangaData = response.parseAs<Manga>()
|
||||
if (!preferences.updateCover && manga.thumbnail_url != mangaData.comic.cover) {
|
||||
if (manga.thumbnail_url.toString().endsWith("#1")) {
|
||||
return mangaData.toSManga(
|
||||
includeMuTags = preferences.includeMuTags,
|
||||
scorePosition = preferences.scorePosition,
|
||||
covers = listOf(
|
||||
MDcovers(
|
||||
b2key = manga.thumbnail_url?.substringBeforeLast("#")
|
||||
?.substringAfterLast("/"),
|
||||
vol = "1",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val coversUrl =
|
||||
"$apiUrl/comic/${mangaData.comic.slug ?: mangaData.comic.hid}/covers?tachiyomi=true"
|
||||
val covers = client.newCall(GET(coversUrl)).execute()
|
||||
.parseAs<Covers>().md_covers.reversed()
|
||||
return mangaData.toSManga(
|
||||
includeMuTags = preferences.includeMuTags,
|
||||
covers = if (covers.any { it.vol == "1" }) covers.filter { it.vol == "1" } else covers,
|
||||
)
|
||||
}
|
||||
return mangaData.toSManga(includeMuTags = preferences.includeMuTags)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl${manga.url.removeSuffix("#")}"
|
||||
}
|
||||
|
||||
/** Manga Chapter List **/
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
// Migration from slug based urls to hid based ones
|
||||
if (!manga.url.endsWith("#")) {
|
||||
throw Exception("Migrate from Comick to Comick")
|
||||
}
|
||||
|
||||
return paginatedChapterListRequest(manga.url.removeSuffix("#"), 1)
|
||||
}
|
||||
|
||||
private fun paginatedChapterListRequest(mangaUrl: String, page: Int): Request {
|
||||
return GET(
|
||||
"$apiUrl$mangaUrl".toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("chapters")
|
||||
if (comickFunLang != "all") addQueryParameter("lang", comickFunLang)
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
addQueryParameter("page", "$page")
|
||||
}.build(),
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val chapterListResponse = response.parseAs<ChapterList>()
|
||||
|
||||
val mangaUrl = response.request.url.toString()
|
||||
.substringBefore("/chapters")
|
||||
.substringAfter(apiUrl)
|
||||
|
||||
var resultSize = chapterListResponse.chapters.size
|
||||
var page = 2
|
||||
|
||||
while (chapterListResponse.total > resultSize) {
|
||||
val newRequest = paginatedChapterListRequest(mangaUrl, page)
|
||||
val newResponse = client.newCall(newRequest).execute()
|
||||
val newChapterListResponse = newResponse.parseAs<ChapterList>()
|
||||
|
||||
chapterListResponse.chapters += newChapterListResponse.chapters
|
||||
|
||||
resultSize += newChapterListResponse.chapters.size
|
||||
page += 1
|
||||
}
|
||||
|
||||
return chapterListResponse.chapters
|
||||
.filter {
|
||||
it.groups.map { g -> g.lowercase() }.intersect(preferences.ignoredGroups).isEmpty()
|
||||
}
|
||||
.map { it.toSChapter(mangaUrl) }
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
return "$baseUrl${chapter.url}"
|
||||
}
|
||||
|
||||
/** Chapter Pages **/
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val chapterHid = chapter.url.substringAfterLast("/").substringBefore("-")
|
||||
return GET("$apiUrl/chapter/$chapterHid?tachiyomi=true", headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<PageList>()
|
||||
return result.chapter.images.mapIndexedNotNull { index, data ->
|
||||
if (data.url == null) null else Page(index = index, imageUrl = data.url)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return json.decodeFromString(body.string())
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException("Not used")
|
||||
}
|
||||
|
||||
override fun getFilterList() = getFilters()
|
||||
|
||||
private fun SharedPreferences.newLineIgnoredGroups() {
|
||||
if (getBoolean(MIGRATED_IGNORED_GROUPS, false)) return
|
||||
val ignoredGroups = getString(IGNORED_GROUPS_PREF, "").orEmpty()
|
||||
|
||||
edit()
|
||||
.putString(
|
||||
IGNORED_GROUPS_PREF,
|
||||
ignoredGroups
|
||||
.split(",")
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.joinToString("\n"),
|
||||
)
|
||||
.putBoolean(MIGRATED_IGNORED_GROUPS, true)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SLUG_SEARCH_PREFIX = "id:"
|
||||
private const val IGNORED_GROUPS_PREF = "IgnoredGroups"
|
||||
private const val INCLUDE_MU_TAGS_PREF = "IncludeMangaUpdatesTags"
|
||||
private const val INCLUDE_MU_TAGS_DEFAULT = false
|
||||
private const val MIGRATED_IGNORED_GROUPS = "MigratedIgnoredGroups"
|
||||
private const val FIRST_COVER_PREF = "DefaultCover"
|
||||
private const val FIRST_COVER_DEFAULT = true
|
||||
private const val SCORE_POSITION_PREF = "ScorePosition"
|
||||
private const val SCORE_POSITION_DEFAULT = "top"
|
||||
private const val limit = 20
|
||||
val dateFormat by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
val markdownLinksRegex = "\\[([^]]+)]\\(([^)]+)\\)".toRegex()
|
||||
val markdownItalicBoldRegex = "\\*+\\s*([^*]*)\\s*\\*+".toRegex()
|
||||
val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Serializable
|
||||
data class SearchManga(
|
||||
val hid: String,
|
||||
val title: String,
|
||||
@SerialName("md_covers") val mdCovers: List<MDcovers> = emptyList(),
|
||||
@SerialName("cover_url") val cover: String? = null,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
// appending # at end as part of migration from slug to hid
|
||||
url = "/comic/$hid#"
|
||||
title = this@SearchManga.title
|
||||
thumbnail_url = parseCover(cover, mdCovers)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val comic: Comic,
|
||||
val artists: List<Name> = emptyList(),
|
||||
val authors: List<Name> = emptyList(),
|
||||
val genres: List<Name> = emptyList(),
|
||||
val demographic: String? = null,
|
||||
) {
|
||||
fun toSManga(
|
||||
includeMuTags: Boolean = false,
|
||||
scorePosition: String = "",
|
||||
covers: List<MDcovers>? = null,
|
||||
) =
|
||||
SManga.create().apply {
|
||||
// appennding # at end as part of migration from slug to hid
|
||||
url = "/comic/${comic.hid}#"
|
||||
title = comic.title
|
||||
description = buildString {
|
||||
if (scorePosition == "top") append(comic.fancyScore)
|
||||
val desc = comic.desc?.beautifyDescription()
|
||||
if (!desc.isNullOrEmpty()) {
|
||||
if (this.isNotEmpty()) append("\n\n")
|
||||
append(desc)
|
||||
}
|
||||
if (scorePosition == "middle") {
|
||||
if (this.isNotEmpty()) append("\n\n")
|
||||
append(comic.fancyScore)
|
||||
}
|
||||
if (comic.altTitles.isNotEmpty()) {
|
||||
if (this.isNotEmpty()) append("\n\n")
|
||||
append("Alternative Titles:\n")
|
||||
append(
|
||||
comic.altTitles.mapNotNull { title ->
|
||||
title.title?.let { "• $it" }
|
||||
}.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
if (scorePosition == "bottom") {
|
||||
if (this.isNotEmpty()) append("\n\n")
|
||||
append(comic.fancyScore)
|
||||
}
|
||||
}
|
||||
|
||||
status = comic.status.parseStatus(comic.translationComplete)
|
||||
thumbnail_url = parseCover(
|
||||
comic.cover,
|
||||
covers ?: comic.mdCovers,
|
||||
)
|
||||
artist = artists.joinToString { it.name.trim() }
|
||||
author = authors.joinToString { it.name.trim() }
|
||||
genre = buildList {
|
||||
comic.origination?.let(::add)
|
||||
demographic?.let { add(Name(it)) }
|
||||
addAll(genres)
|
||||
addAll(comic.mdGenres.mapNotNull { it.name })
|
||||
if (includeMuTags) {
|
||||
comic.muGenres.categories.forEach { category ->
|
||||
category?.category?.title?.let { add(Name(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctBy { it.name }
|
||||
.filter { it.name.isNotBlank() }
|
||||
.joinToString { it.name.trim() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Comic(
|
||||
val hid: String,
|
||||
val title: String,
|
||||
val country: String? = null,
|
||||
val slug: String? = null,
|
||||
@SerialName("md_titles") val altTitles: List<Title> = emptyList(),
|
||||
val desc: String? = null,
|
||||
val status: Int? = 0,
|
||||
@SerialName("translation_completed") val translationComplete: Boolean? = true,
|
||||
@SerialName("md_covers") val mdCovers: List<MDcovers> = emptyList(),
|
||||
@SerialName("cover_url") val cover: String? = null,
|
||||
@SerialName("md_comic_md_genres") val mdGenres: List<MdGenres>,
|
||||
@SerialName("mu_comics") val muGenres: MuComicCategories = MuComicCategories(emptyList()),
|
||||
@SerialName("bayesian_rating") val score: String? = null,
|
||||
) {
|
||||
val origination = when (country) {
|
||||
"jp" -> Name("Manga")
|
||||
"kr" -> Name("Manhwa")
|
||||
"cn" -> Name("Manhua")
|
||||
else -> null
|
||||
}
|
||||
val fancyScore: String = if (score.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
val stars = score.toBigDecimal().div(BigDecimal(2))
|
||||
.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
buildString {
|
||||
append("★".repeat(stars))
|
||||
if (stars < 5) append("☆".repeat(5 - stars))
|
||||
append(" $score")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MdGenres(
|
||||
@SerialName("md_genres") val name: Name? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MuComicCategories(
|
||||
@SerialName("mu_comic_categories") val categories: List<MuCategories?> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MuCategories(
|
||||
@SerialName("mu_categories") val category: Title? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Covers(
|
||||
val md_covers: List<MDcovers> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MDcovers(
|
||||
val b2key: String?,
|
||||
val vol: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Title(
|
||||
val title: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Name(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterList(
|
||||
val chapters: MutableList<Chapter>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
val hid: String,
|
||||
val lang: String = "",
|
||||
val title: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
val chap: String = "",
|
||||
val vol: String = "",
|
||||
@SerialName("group_name") val groups: List<String> = emptyList(),
|
||||
) {
|
||||
fun toSChapter(mangaUrl: String) = SChapter.create().apply {
|
||||
url = "$mangaUrl/$hid-chapter-$chap-$lang"
|
||||
name = beautifyChapterName(vol, chap, title)
|
||||
date_upload = createdAt.parseDate()
|
||||
scanlator = groups.joinToString().takeUnless { it.isBlank() } ?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PageList(
|
||||
val chapter: ChapterPageData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPageData(
|
||||
val images: List<Page>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Page(
|
||||
val url: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
// A legacy mapping of language codes to ensure that source IDs don't change
|
||||
val legacyLanguageMappings = mapOf(
|
||||
"pt-br" to "pt-BR", // Brazilian Portuguese
|
||||
"zh-hk" to "zh-Hant", // Traditional Chinese,
|
||||
"zh" to "zh-Hans", // Simplified Chinese
|
||||
).withDefault { it } // country code matches language code
|
||||
|
||||
class ComickFunFactory : SourceFactory {
|
||||
private val idMap = listOf(
|
||||
"all" to 982606170401027267,
|
||||
"en" to 2971557565147974499,
|
||||
"pt-br" to 8729626158695297897,
|
||||
"ru" to 5846182885417171581,
|
||||
"fr" to 9126078936214680667,
|
||||
"es-419" to 3182432228546767958,
|
||||
"pl" to 7005108854993254607,
|
||||
"tr" to 7186425300860782365,
|
||||
"it" to 8807318985460553537,
|
||||
"es" to 9052019484488287695,
|
||||
"id" to 5506707690027487154,
|
||||
"hu" to 7838940669485160901,
|
||||
"vi" to 9191587139933034493,
|
||||
"zh-hk" to 3140511316190656180,
|
||||
"ar" to 8266599095155001097,
|
||||
"de" to 7552236568334706863,
|
||||
"zh" to 1071494508319622063,
|
||||
"ca" to 2159382907508433047,
|
||||
"bg" to 8981320463367739957,
|
||||
"th" to 4246541831082737053,
|
||||
"fa" to 3146252372540608964,
|
||||
"uk" to 3505068018066717349,
|
||||
"mn" to 2147260678391898600,
|
||||
"ro" to 6676949771764486043,
|
||||
"he" to 5354540502202034685,
|
||||
"ms" to 4731643595200952045,
|
||||
"tl" to 8549617092958820123,
|
||||
"ja" to 8288710818308434509,
|
||||
"hi" to 5176570178081213805,
|
||||
"my" to 9199495862098963317,
|
||||
"ko" to 3493720175703105662,
|
||||
"cs" to 2651978322082769022,
|
||||
"pt" to 4153491877797434408,
|
||||
"nl" to 6104206360977276112,
|
||||
"sv" to 979314012722687145,
|
||||
"bn" to 3598159956413889411,
|
||||
"no" to 5932005504194733317,
|
||||
"lt" to 1792260331167396074,
|
||||
"el" to 6190162673651111756,
|
||||
"sr" to 571668187470919545,
|
||||
"da" to 7137437402245830147,
|
||||
).toMap()
|
||||
override fun createSources(): List<Source> = idMap.keys.map {
|
||||
object : ComickFun(legacyLanguageMappings.getValue(it), it) {
|
||||
override val id: Long = idMap[it]!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header(name = "The filter is ignored when using text search."),
|
||||
GenreFilter("Genre", getGenresList),
|
||||
DemographicFilter("Demographic", getDemographicList),
|
||||
TypeFilter("Type", getTypeList),
|
||||
SortFilter("Sort", getSortsList),
|
||||
StatusFilter("Status", getStatusList),
|
||||
CompletedFilter("Completely Scanlated?"),
|
||||
CreatedAtFilter("Created at", getCreatedAtList),
|
||||
MinimumFilter("Minimum Chapters"),
|
||||
Filter.Header("From Year, ex: 2010"),
|
||||
FromYearFilter("From"),
|
||||
Filter.Header("To Year, ex: 2021"),
|
||||
ToYearFilter("To"),
|
||||
Filter.Header("Separate tags with commas"),
|
||||
TagFilter("Tags"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Filters **/
|
||||
internal class GenreFilter(name: String, genreList: List<Pair<String, String>>) :
|
||||
Filter.Group<TriFilter>(name, genreList.map { TriFilter(it.first, it.second) })
|
||||
|
||||
internal class TagFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class DemographicFilter(name: String, demographicList: List<Pair<String, String>>) :
|
||||
Filter.Group<TriFilter>(name, demographicList.map { TriFilter(it.first, it.second) })
|
||||
|
||||
internal class TypeFilter(name: String, typeList: List<Pair<String, String>>) :
|
||||
Filter.Group<CheckBoxFilter>(name, typeList.map { CheckBoxFilter(it.first, it.second) })
|
||||
|
||||
internal class CompletedFilter(name: String) : CheckBoxFilter(name)
|
||||
|
||||
internal class CreatedAtFilter(name: String, createdAtList: List<Pair<String, String>>) :
|
||||
SelectFilter(name, createdAtList)
|
||||
|
||||
internal class MinimumFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class FromYearFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class ToYearFilter(name: String) : TextFilter(name)
|
||||
|
||||
internal class SortFilter(name: String, sortList: List<Pair<String, String>>, state: Int = 0) :
|
||||
SelectFilter(name, sortList, state)
|
||||
|
||||
internal class StatusFilter(name: String, statusList: List<Pair<String, String>>, state: Int = 0) :
|
||||
SelectFilter(name, statusList, state)
|
||||
|
||||
/** Generics **/
|
||||
internal open class TriFilter(name: String, val value: String) : Filter.TriState(name)
|
||||
|
||||
internal open class TextFilter(name: String) : Filter.Text(name)
|
||||
|
||||
internal open class CheckBoxFilter(name: String, val value: String = "") : Filter.CheckBox(name)
|
||||
|
||||
internal open class SelectFilter(name: String, private val vals: List<Pair<String, String>>, state: Int = 0) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
||||
fun getValue() = vals[state].second
|
||||
}
|
||||
|
||||
/** Filters Data **/
|
||||
private val getGenresList: List<Pair<String, String>> = listOf(
|
||||
Pair("4-Koma", "4-koma"),
|
||||
Pair("Action", "action"),
|
||||
Pair("Adaptation", "adaptation"),
|
||||
Pair("Adult", "adult"),
|
||||
Pair("Adventure", "adventure"),
|
||||
Pair("Aliens", "aliens"),
|
||||
Pair("Animals", "animals"),
|
||||
Pair("Anthology", "anthology"),
|
||||
Pair("Award Winning", "award-winning"),
|
||||
Pair("Comedy", "comedy"),
|
||||
Pair("Cooking", "cooking"),
|
||||
Pair("Crime", "crime"),
|
||||
Pair("Crossdressing", "crossdressing"),
|
||||
Pair("Delinquents", "delinquents"),
|
||||
Pair("Demons", "demons"),
|
||||
Pair("Doujinshi", "doujinshi"),
|
||||
Pair("Drama", "drama"),
|
||||
Pair("Ecchi", "ecchi"),
|
||||
Pair("Fan Colored", "fan-colored"),
|
||||
Pair("Fantasy", "fantasy"),
|
||||
Pair("Full Color", "full-color"),
|
||||
Pair("Gender Bender", "gender-bender"),
|
||||
Pair("Genderswap", "genderswap"),
|
||||
Pair("Ghosts", "ghosts"),
|
||||
Pair("Gore", "gore"),
|
||||
Pair("Gyaru", "gyaru"),
|
||||
Pair("Harem", "harem"),
|
||||
Pair("Historical", "historical"),
|
||||
Pair("Horror", "horror"),
|
||||
Pair("Incest", "incest"),
|
||||
Pair("Isekai", "isekai"),
|
||||
Pair("Loli", "loli"),
|
||||
Pair("Long Strip", "long-strip"),
|
||||
Pair("Mafia", "mafia"),
|
||||
Pair("Magic", "magic"),
|
||||
Pair("Magical Girls", "magical-girls"),
|
||||
Pair("Martial Arts", "martial-arts"),
|
||||
Pair("Mature", "mature"),
|
||||
Pair("Mecha", "mecha"),
|
||||
Pair("Medical", "medical"),
|
||||
Pair("Military", "military"),
|
||||
Pair("Monster Girls", "monster-girls"),
|
||||
Pair("Monsters", "monsters"),
|
||||
Pair("Music", "music"),
|
||||
Pair("Mystery", "mystery"),
|
||||
Pair("Ninja", "ninja"),
|
||||
Pair("Office Workers", "office-workers"),
|
||||
Pair("Official Colored", "official-colored"),
|
||||
Pair("Oneshot", "oneshot"),
|
||||
Pair("Philosophical", "philosophical"),
|
||||
Pair("Police", "police"),
|
||||
Pair("Post-Apocalyptic", "post-apocalyptic"),
|
||||
Pair("Psychological", "psychological"),
|
||||
Pair("Reincarnation", "reincarnation"),
|
||||
Pair("Reverse Harem", "reverse-harem"),
|
||||
Pair("Romance", "romance"),
|
||||
Pair("Samurai", "samurai"),
|
||||
Pair("School Life", "school-life"),
|
||||
Pair("Sci-Fi", "sci-fi"),
|
||||
Pair("Sexual Violence", "sexual-violence"),
|
||||
Pair("Shota", "shota"),
|
||||
Pair("Shoujo Ai", "shoujo-ai"),
|
||||
Pair("Shounen Ai", "shounen-ai"),
|
||||
Pair("Slice of Life", "slice-of-life"),
|
||||
Pair("Smut", "smut"),
|
||||
Pair("Sports", "sports"),
|
||||
Pair("Superhero", "superhero"),
|
||||
Pair("Supernatural", "supernatural"),
|
||||
Pair("Survival", "survival"),
|
||||
Pair("Thriller", "thriller"),
|
||||
Pair("Time Travel", "time-travel"),
|
||||
Pair("Traditional Games", "traditional-games"),
|
||||
Pair("Tragedy", "tragedy"),
|
||||
Pair("User Created", "user-created"),
|
||||
Pair("Vampires", "vampires"),
|
||||
Pair("Video Games", "video-games"),
|
||||
Pair("Villainess", "villainess"),
|
||||
Pair("Virtual Reality", "virtual-reality"),
|
||||
Pair("Web Comic", "web-comic"),
|
||||
Pair("Wuxia", "wuxia"),
|
||||
Pair("Yaoi", "yaoi"),
|
||||
Pair("Yuri", "yuri"),
|
||||
Pair("Zombies", "zombies"),
|
||||
)
|
||||
|
||||
private val getDemographicList: List<Pair<String, String>> = listOf(
|
||||
Pair("Shounen", "1"),
|
||||
Pair("Shoujo", "2"),
|
||||
Pair("Seinen", "3"),
|
||||
Pair("Josei", "4"),
|
||||
)
|
||||
|
||||
private val getTypeList: List<Pair<String, String>> = listOf(
|
||||
Pair("Manga", "jp"),
|
||||
Pair("Manhwa", "kr"),
|
||||
Pair("Manhua", "cn"),
|
||||
)
|
||||
|
||||
private val getCreatedAtList: List<Pair<String, String>> = listOf(
|
||||
Pair("", ""),
|
||||
Pair("3 days", "3"),
|
||||
Pair("7 days", "7"),
|
||||
Pair("30 days", "30"),
|
||||
Pair("3 months", "90"),
|
||||
Pair("6 months", "180"),
|
||||
Pair("1 year", "365"),
|
||||
)
|
||||
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Most popular", "follow"),
|
||||
Pair("Most follows", "user_follow_count"),
|
||||
Pair("Most views", "view"),
|
||||
Pair("High rating", "rating"),
|
||||
Pair("Last updated", "uploaded"),
|
||||
Pair("Newest", "created_at"),
|
||||
)
|
||||
|
||||
private val getStatusList: List<Pair<String, String>> = listOf(
|
||||
Pair("All", "0"),
|
||||
Pair("Ongoing", "1"),
|
||||
Pair("Completed", "2"),
|
||||
Pair("Cancelled", "3"),
|
||||
Pair("Hiatus", "4"),
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.extension.all.comickfun.ComickFun.Companion.dateFormat
|
||||
import eu.kanade.tachiyomi.extension.all.comickfun.ComickFun.Companion.markdownItalicBoldRegex
|
||||
import eu.kanade.tachiyomi.extension.all.comickfun.ComickFun.Companion.markdownItalicRegex
|
||||
import eu.kanade.tachiyomi.extension.all.comickfun.ComickFun.Companion.markdownLinksRegex
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.jsoup.parser.Parser
|
||||
|
||||
internal fun String.beautifyDescription(): String {
|
||||
return Parser.unescapeEntities(this, false)
|
||||
.substringBefore("---")
|
||||
.replace(markdownLinksRegex, "")
|
||||
.replace(markdownItalicBoldRegex, "")
|
||||
.replace(markdownItalicRegex, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
internal fun Int?.parseStatus(translationComplete: Boolean?): Int {
|
||||
return when (this) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> {
|
||||
if (translationComplete == true) {
|
||||
SManga.COMPLETED
|
||||
} else {
|
||||
SManga.PUBLISHING_FINISHED
|
||||
}
|
||||
}
|
||||
3 -> SManga.CANCELLED
|
||||
4 -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseCover(thumbnailUrl: String?, mdCovers: List<MDcovers>): String? {
|
||||
val b2key = mdCovers.firstOrNull()?.b2key
|
||||
?: return thumbnailUrl
|
||||
val vol = mdCovers.firstOrNull()?.vol.orEmpty()
|
||||
|
||||
return thumbnailUrl?.replaceAfterLast("/", "$b2key#$vol")
|
||||
}
|
||||
|
||||
internal fun thumbnailIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val frag = request.url.fragment
|
||||
if (frag.isNullOrEmpty()) return chain.proceed(request)
|
||||
val response = chain.proceed(request)
|
||||
if (!response.isSuccessful && response.code == 404) {
|
||||
response.close()
|
||||
val url = request.url.toString()
|
||||
.replaceAfterLast("/", frag)
|
||||
|
||||
return chain.proceed(
|
||||
request.newBuilder()
|
||||
.url(url)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
internal fun beautifyChapterName(vol: String, chap: String, title: String): String {
|
||||
return buildString {
|
||||
if (vol.isNotEmpty()) {
|
||||
if (chap.isEmpty()) append("Volume $vol") else append("Vol. $vol")
|
||||
}
|
||||
if (chap.isNotEmpty()) {
|
||||
if (vol.isEmpty()) append("Chapter $chap") else append(", Ch. $chap")
|
||||
}
|
||||
if (title.isNotEmpty()) {
|
||||
if (chap.isEmpty()) append(title) else append(": $title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.parseDate(): Long {
|
||||
return runCatching { dateFormat.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
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 ComickFunUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val slug = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${ComickFun.SLUG_SEARCH_PREFIX}$slug")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("ComickFunUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("ComickFunUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
2
src/all/comico/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
17
src/all/comico/build.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Comico'
|
||||
pkgNameSuffix = 'all.comico'
|
||||
extClass = '.ComicoFactory'
|
||||
extVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib-cryptoaes'))
|
||||
}
|
||||
BIN
src/all/comico/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/comico/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/all/comico/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/all/comico/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src/all/comico/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,258 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comico
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||
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.HttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
open class Comico(
|
||||
final override val baseUrl: String,
|
||||
final override val name: String,
|
||||
private val langCode: String,
|
||||
) : HttpSource() {
|
||||
final override val supportsLatest = true
|
||||
|
||||
override val lang = langCode.substring(0, 2)
|
||||
|
||||
protected open val apiUrl = baseUrl.replace("www", "api")
|
||||
|
||||
private val json by injectLazy<Json>()
|
||||
|
||||
private val cookieManager by lazy { CookieManager.getInstance() }
|
||||
|
||||
private val imgHeaders by lazy {
|
||||
headersBuilder().set("Accept", ACCEPT_IMAGE).build()
|
||||
}
|
||||
|
||||
private val apiHeaders: Headers
|
||||
get() = headersBuilder().apply {
|
||||
val time = System.currentTimeMillis() / 1000L
|
||||
this["X-comico-request-time"] = time.toString()
|
||||
this["X-comico-check-sum"] = sha256(time)
|
||||
this["X-comico-client-immutable-uid"] = ANON_IP
|
||||
this["X-comico-client-accept-mature"] = "Y"
|
||||
this["X-comico-client-platform"] = "web"
|
||||
this["X-comico-client-store"] = "other"
|
||||
this["X-comico-client-os"] = "aos"
|
||||
this["Origin"] = baseUrl
|
||||
}.build()
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.cookieJar(
|
||||
object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) =
|
||||
cookies.filter { it.matches(url) }.forEach {
|
||||
cookieManager.setCookie(url.toString(), it.toString())
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl) =
|
||||
cookieManager.getCookie(url.toString())?.split("; ")
|
||||
?.mapNotNull { Cookie.parse(url, it) } ?: emptyList()
|
||||
},
|
||||
).build()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.set("Accept-Language", langCode)
|
||||
.set("User-Agent", userAgent)
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
paginate("all_comic/daily/$day", page)
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
paginate("all_comic/ranking/trending", page)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
if (query.isEmpty()) {
|
||||
paginate("all_comic/read_for_free", page)
|
||||
} else {
|
||||
POST("$apiUrl/search", apiHeaders, search(query, page))
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
GET(apiUrl + manga.url + "/episode", apiHeaders)
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) =
|
||||
GET(apiUrl + chapter.url, apiHeaders)
|
||||
|
||||
override fun imageRequest(page: Page) =
|
||||
GET(page.imageUrl!!, imgHeaders)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
popularMangaParse(response)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val data = response.data
|
||||
val hasNext = data["page"]["hasNext"]
|
||||
val mangas = data.map<ContentInfo, SManga>("contents") {
|
||||
SManga.create().apply {
|
||||
title = it.name
|
||||
url = "/comic/${it.id}"
|
||||
thumbnail_url = it.cover
|
||||
description = it.description
|
||||
status = when (it.status) {
|
||||
"completed" -> SManga.COMPLETED
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
author = it.authors?.filter { it.isAuthor }?.joinToString()
|
||||
artist = it.authors?.filter { it.isArtist }?.joinToString()
|
||||
genre = buildString {
|
||||
it.genres?.joinTo(this)
|
||||
if (it.mature) append(", Mature")
|
||||
if (it.original) append(", Original")
|
||||
if (it.exclusive) append(", Exclusive")
|
||||
}
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, hasNext.jsonPrimitive.boolean)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) =
|
||||
popularMangaParse(response)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val content = response.data["episode"]["content"]
|
||||
val id = content["id"].jsonPrimitive.int
|
||||
return content.map<Chapter, SChapter>("chapters") {
|
||||
SChapter.create().apply {
|
||||
chapter_number = it.id.toFloat()
|
||||
url = "/comic/$id/chapter/${it.id}/product"
|
||||
name = it.name + if (it.isAvailable) "" else LOCK
|
||||
date_upload = dateFormat.parse(it.publishedAt)?.time ?: 0L
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response) =
|
||||
response.data["chapter"].map<ChapterImage, Page>("images") {
|
||||
Page(it.sort, "", it.url.decrypt() + "?" + it.parameter)
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga) =
|
||||
rx.Observable.just(manga.apply { initialized = true })!!
|
||||
|
||||
override fun fetchPageList(chapter: SChapter) =
|
||||
if (!chapter.name.endsWith(LOCK)) {
|
||||
super.fetchPageList(chapter)
|
||||
} else {
|
||||
throw Error("You are not authorized to view this!")
|
||||
}
|
||||
|
||||
private fun search(query: String, page: Int) =
|
||||
FormBody.Builder().add("query", query)
|
||||
.add("pageNo", (page - 1).toString())
|
||||
.add("pageSize", "25").build()
|
||||
|
||||
private fun paginate(route: String, page: Int) =
|
||||
GET("$apiUrl/$route?pageNo=${page - 1}&pageSize=25", apiHeaders)
|
||||
|
||||
private fun String.decrypt() =
|
||||
CryptoAES.decrypt(this, keyBytes, ivBytes)
|
||||
|
||||
private val Response.data: JsonElement?
|
||||
get() = json.parseToJsonElement(body.string()).jsonObject.also {
|
||||
val code = it["result"]["code"].jsonPrimitive.int
|
||||
if (code != 200) throw Error(status(code))
|
||||
}["data"]
|
||||
|
||||
private operator fun JsonElement?.get(key: String) =
|
||||
this!!.jsonObject[key]!!
|
||||
|
||||
private inline fun <reified T, R> JsonElement?.map(
|
||||
key: String,
|
||||
transform: (T) -> R,
|
||||
) = json.decodeFromJsonElement<List<T>>(this[key]).map(transform)
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used")
|
||||
|
||||
override fun imageUrlParse(response: Response) =
|
||||
throw UnsupportedOperationException("Not used")
|
||||
|
||||
companion object {
|
||||
private const val ANON_IP = "0.0.0.0"
|
||||
|
||||
private const val LOCK = " \uD83D\uDD12"
|
||||
|
||||
private const val ISO_DATE = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||
|
||||
private const val WEB_KEY = "9241d2f090d01716feac20ae08ba791a"
|
||||
|
||||
private const val AES_KEY = "a7fc9dc89f2c873d79397f8a0028a4cd"
|
||||
|
||||
private val keyBytes = AES_KEY.toByteArray(Charsets.UTF_8)
|
||||
|
||||
private val ivBytes = ByteArray(16) // Zero filled array as IV
|
||||
|
||||
private const val ACCEPT_IMAGE =
|
||||
"image/avif,image/jxl,image/webp,image/*,*/*"
|
||||
|
||||
private val userAgent = System.getProperty("http.agent")!!
|
||||
|
||||
private val dateFormat = SimpleDateFormat(ISO_DATE, Locale.ROOT)
|
||||
|
||||
private val SHA256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
private val day by lazy {
|
||||
when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) {
|
||||
Calendar.SUNDAY -> "sunday"
|
||||
Calendar.MONDAY -> "monday"
|
||||
Calendar.TUESDAY -> "tuesday"
|
||||
Calendar.WEDNESDAY -> "wednesday"
|
||||
Calendar.THURSDAY -> "thursday"
|
||||
Calendar.FRIDAY -> "friday"
|
||||
Calendar.SATURDAY -> "saturday"
|
||||
else -> "completed"
|
||||
}
|
||||
}
|
||||
|
||||
fun sha256(timestamp: Long) = buildString(64) {
|
||||
SHA256.digest((WEB_KEY + ANON_IP + timestamp).toByteArray())
|
||||
.joinTo(this, "") { "%02x".format(it) }
|
||||
SHA256.reset()
|
||||
}
|
||||
|
||||
private fun status(code: Int) = when (code) {
|
||||
400 -> "Bad Request"
|
||||
401 -> "Unauthorized"
|
||||
402 -> "Payment Required"
|
||||
403 -> "Forbidden"
|
||||
404 -> "Not Found"
|
||||
408 -> "Request Timeout"
|
||||
409 -> "Conflict"
|
||||
410 -> "DormantAccount"
|
||||
417 -> "Expectation Failed"
|
||||
426 -> "Upgrade Required"
|
||||
428 -> "성인 on/off 권한"
|
||||
429 -> "Too Many Requests"
|
||||
500 -> "Internal Server Error"
|
||||
503 -> "Service Unavailable"
|
||||
451 -> "성인 인증"
|
||||
else -> "Error $code"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comico
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class ComicoFactory : SourceFactory {
|
||||
open class PocketComics(langCode: String) :
|
||||
Comico("https://www.pocketcomics.com", "POCKET COMICS", langCode)
|
||||
|
||||
class ComicoJP : Comico("https://www.comico.jp", "コミコ", "ja-JP")
|
||||
|
||||
class ComicoKR : Comico("https://www.comico.kr", "코미코", "ko-KR")
|
||||
|
||||
override fun createSources() = listOf(
|
||||
PocketComics("en-US"),
|
||||
PocketComics("zh-TW"),
|
||||
ComicoJP(),
|
||||
ComicoKR(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comico
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ContentInfo(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val original: Boolean,
|
||||
val exclusive: Boolean,
|
||||
val mature: Boolean,
|
||||
val status: String? = null,
|
||||
val genres: List<Genre>? = null,
|
||||
val authors: List<Author>? = null,
|
||||
private val thumbnails: List<Thumbnail>,
|
||||
) {
|
||||
val cover: String
|
||||
get() = thumbnails[0].toString()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Thumbnail(private val url: String) {
|
||||
override fun toString() = url
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Author(private val name: String, private val role: String) {
|
||||
val isAuthor: Boolean
|
||||
get() = role == "creator" ||
|
||||
role == "writer" ||
|
||||
role == "original_creator"
|
||||
|
||||
val isArtist: Boolean
|
||||
get() = role == "creator" ||
|
||||
role == "artist" ||
|
||||
role == "studio" ||
|
||||
role == "assistant"
|
||||
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Genre(private val name: String) {
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val publishedAt: String,
|
||||
private val salesConfig: SalesConfig,
|
||||
private val hasTrial: Boolean,
|
||||
private val activity: Activity,
|
||||
) {
|
||||
val isAvailable: Boolean
|
||||
get() = salesConfig.free || hasTrial || activity.owned
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SalesConfig(val free: Boolean)
|
||||
|
||||
@Serializable
|
||||
data class Activity(val rented: Boolean, val unlocked: Boolean) {
|
||||
inline val owned: Boolean
|
||||
get() = rented || unlocked
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChapterImage(
|
||||
val sort: Int,
|
||||
val url: String,
|
||||
val parameter: String,
|
||||
)
|
||||
2
src/all/commitstrip/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
11
src/all/commitstrip/build.gradle
Normal file
@@ -0,0 +1,11 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Commit Strip'
|
||||
pkgNameSuffix = 'all.commitstrip'
|
||||
extClass = '.CommitStripFactory'
|
||||
extVersionCode = 3
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/commitstrip/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/all/commitstrip/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/all/commitstrip/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/all/commitstrip/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/all/commitstrip/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/all/commitstrip/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,192 @@
|
||||
package eu.kanade.tachiyomi.extension.all.commitstrip
|
||||
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
abstract class CommitStrip(
|
||||
override val lang: String,
|
||||
private val siteLang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val name = "Commit Strip"
|
||||
override val baseUrl = "https://www.commitstrip.com"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
// Helper
|
||||
|
||||
private fun createManga(year: Int): SManga = SManga.create().apply {
|
||||
url = "$baseUrl/$siteLang/$year"
|
||||
title = "$name ($year)"
|
||||
thumbnail_url = when (lang) {
|
||||
"en" -> LOGO_EN
|
||||
"fr" -> LOGO_FR
|
||||
else -> LOGO_EN
|
||||
}
|
||||
author = when (lang) {
|
||||
"en" -> AUTHOR_EN
|
||||
"fr" -> AUTHOR_FR
|
||||
else -> AUTHOR_EN
|
||||
}
|
||||
artist = ARTIST
|
||||
status = if (year != currentYear) SManga.COMPLETED else SManga.ONGOING
|
||||
description = when (lang) {
|
||||
"en" -> "$SUMMARY_EN $NOTE $year"
|
||||
"fr" -> "$SUMMARY_FR $NOTE $year"
|
||||
else -> "$SUMMARY_EN $NOTE $year"
|
||||
}
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
// have one manga entry for each year
|
||||
return (currentYear downTo 2012)
|
||||
.map { createManga(it) }
|
||||
.let { Observable.just(MangasPage(it, false))!! }
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
fetchPopularManga(1).map { mangaList ->
|
||||
mangaList.copy(mangaList.mangas.filter { it.title.contains(query) })
|
||||
}
|
||||
|
||||
// Details
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga) = Observable.just(
|
||||
manga.apply {
|
||||
initialized = true
|
||||
},
|
||||
)!!
|
||||
|
||||
// Open in WebView
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("${manga.url}/?", headers)
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
// create a new call to parse the no of pages in the site
|
||||
// example responseString - Page 1 of 11
|
||||
val responseString = client.newCall(GET(manga.url, headers)).execute().run {
|
||||
asJsoup().selectFirst(".wp-pagenavi .pages")?.text() ?: "1"
|
||||
}
|
||||
// use regex to get the last number (i.e. 11 above)
|
||||
val pages = Regex("\\d+").findAll(responseString).last().value.toInt()
|
||||
|
||||
return (1..pages).map {
|
||||
val response = chapterListRequest(manga, it)
|
||||
chapterListParse(response)
|
||||
}.let { Observable.just(it.flatten()) }
|
||||
}
|
||||
|
||||
private fun chapterListRequest(manga: SManga, page: Int): Response =
|
||||
client.newCall(GET("${manga.url}/page/$page", headers)).execute().run {
|
||||
if (!isSuccessful) {
|
||||
close()
|
||||
throw Exception("HTTP error $code")
|
||||
}
|
||||
this
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return super.chapterListParse(response).reversed().distinct().mapIndexed { index, chapter ->
|
||||
chapter.apply { chapter_number = index.toFloat() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".excerpt a"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
url = "$baseUrl/$siteLang" + element.attr("href").substringAfter(baseUrl)
|
||||
|
||||
// get the chapter date from the url
|
||||
val date = Regex("\\d{4}\\/\\d{2}\\/\\d{2}").find(url)?.value
|
||||
val parsedDate = date?.let { SimpleDateFormat("yyyy/MM/dd", Locale.US).parse(it) }
|
||||
date_upload = parsedDate?.time ?: 0L
|
||||
|
||||
name = element.select("span").text()
|
||||
}
|
||||
|
||||
// Page
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(GET(chapter.url, headers)).execute().run {
|
||||
asJsoup().select(".entry-content p img").attr("src")
|
||||
}.let {
|
||||
Observable.just(listOf(Page(0, "", it)))
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw Exception("Not Used")
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw Exception("Not used")
|
||||
|
||||
override fun popularMangaSelector() = throw Exception("Not used")
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = throw Exception("Not used")
|
||||
|
||||
override fun searchMangaNextPageSelector() = throw Exception("Not used")
|
||||
|
||||
override fun searchMangaSelector() = throw Exception("Not used")
|
||||
|
||||
override fun popularMangaRequest(page: Int) = throw Exception("Not used")
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("Not used")
|
||||
|
||||
override fun popularMangaNextPageSelector() = throw Exception("Not used")
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = throw Exception("Not used")
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = throw Exception("Not used")
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = throw Exception("Not used")
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = throw Exception("Not used")
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw Exception("Not used")
|
||||
|
||||
override fun latestUpdatesSelector() = throw Exception("Not used")
|
||||
|
||||
companion object {
|
||||
private const val LOGO_EN = "https://i.imgur.com/HODJlt9.jpg"
|
||||
|
||||
private const val LOGO_FR = "https://i.imgur.com/I7ps9zS.jpg"
|
||||
|
||||
private const val AUTHOR_EN = "Mark Nightingale"
|
||||
|
||||
private const val AUTHOR_FR = "Thomas Gx"
|
||||
|
||||
private const val ARTIST = "Etienne Issartial"
|
||||
|
||||
private const val SUMMARY_EN = "The blog relating the daily life of web agency developers."
|
||||
|
||||
private const val SUMMARY_FR = "Le blog qui raconte la vie des codeurs"
|
||||
|
||||
private const val NOTE = "\n\nNote: This entry includes all the chapters published in"
|
||||
|
||||
private val currentYear by lazy {
|
||||
Calendar.getInstance()[Calendar.YEAR]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package eu.kanade.tachiyomi.extension.all.commitstrip
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class CommitStripFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
CommitStripEnglish(),
|
||||
CommitStripFrench(),
|
||||
)
|
||||
}
|
||||
|
||||
class CommitStripEnglish() : CommitStrip("en", "en")
|
||||
class CommitStripFrench() : CommitStrip("fr", "fr")
|
||||
100
src/all/cubari/AndroidManifest.xml
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.cubari.CubariUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<!-- We need another intent filter so the /a/..* shortcut -->
|
||||
<!-- doesn't pollute the cubari one, since they work in any combination -->
|
||||
<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="*.cubari.moe" />
|
||||
<data android:host="cubari.moe" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/read/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/proxy/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*.guya.moe" />
|
||||
<data android:host="guya.moe" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/proxy/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*.imgur.com" />
|
||||
<data android:host="imgur.com" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/a/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/gallery/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*.reddit.com" />
|
||||
<data android:host="reddit.com" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/gallery/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*.imgchest.com" />
|
||||
<data android:host="imgchest.com" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/p/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<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="*.catbox.moe" />
|
||||
<data android:host="catbox.moe" />
|
||||
|
||||
<data
|
||||
android:pathPattern="/c/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
25
src/all/cubari/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Cubari
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [Why do I see no manga?](#why-do-i-see-no-manga)
|
||||
- [Where can I get more information about Cubari?](#where-can-i-get-more-information-about-cubari)
|
||||
- [How do I add a gallery to Cubari?](#how-do-i-add-a-gallery-to-cubari)
|
||||
|
||||
[Uncomment this if needed; and replace ( and ) with ( and )]: <> (- [Guides](#Guides))
|
||||
|
||||
Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation)
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why do I see no manga?
|
||||
Cubari is a proxy for image galleries.
|
||||
If you've setup the Remote Storage via WebView the Recent tab shows your recent, unpinned entries, conversely the Popular tab shows your pinned entries.
|
||||
|
||||
### Where can I get more information about Cubari?
|
||||
You can visit the [Cubari](https://cubari.moe/) website for for more information.
|
||||
|
||||
### How do I add a gallery to Cubari?
|
||||
You can directly open a imgur or Cubari link in the extension.
|
||||
|
||||
[Uncomment this if needed]: <> (## Guides)
|
||||
12
src/all/cubari/build.gradle
Normal file
@@ -0,0 +1,12 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Cubari'
|
||||
pkgNameSuffix = "all.cubari"
|
||||
extClass = '.CubariFactory'
|
||||
extVersionCode = 23
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/cubari/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/all/cubari/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/all/cubari/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/all/cubari/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/all/cubari/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/all/cubari/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,418 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
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.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.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
open class Cubari(override val lang: String) : HttpSource() {
|
||||
|
||||
final override val name = "Cubari"
|
||||
|
||||
final override val baseUrl = "https://cubari.moe"
|
||||
|
||||
final override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add(
|
||||
"User-Agent",
|
||||
"(Android ${Build.VERSION.RELEASE}; " +
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}) " +
|
||||
"Tachiyomi/${AppInfo.getVersionName()} " +
|
||||
Build.ID,
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.HomeInterceptor())
|
||||
.build()
|
||||
.newCall(latestUpdatesRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response -> latestUpdatesParse(response) }
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
return parseMangaList(result, SortType.UNPINNED)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.HomeInterceptor())
|
||||
.build()
|
||||
.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response -> popularMangaParse(response) }
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
return parseMangaList(result, SortType.PINNED)
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response -> mangaDetailsParse(response, manga) }
|
||||
}
|
||||
|
||||
// Called when the series is loaded, or when opening in browser
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl${manga.url}", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
return parseManga(result, manga)
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservable()
|
||||
.map { response -> chapterListParse(response, manga) }
|
||||
}
|
||||
|
||||
// Gets the chapter list based on the series being viewed
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val urlComponents = manga.url.split("/")
|
||||
val source = urlComponents[2]
|
||||
val slug = urlComponents[3]
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
// Called after the request
|
||||
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
|
||||
val res = response.body.string()
|
||||
return parseChapterList(res, manga)
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return when {
|
||||
chapter.url.contains("/chapter/") -> {
|
||||
client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
directPageListParse(response)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
seriesJsonPageListParse(response, chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return when {
|
||||
chapter.url.contains("/chapter/") -> {
|
||||
GET("$baseUrl${chapter.url}", headers)
|
||||
}
|
||||
else -> {
|
||||
val url = chapter.url.split("/")
|
||||
val source = url[2]
|
||||
val slug = url[3]
|
||||
|
||||
GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun directPageListParse(response: Response): List<Page> {
|
||||
val res = response.body.string()
|
||||
val pages = json.parseToJsonElement(res).jsonArray
|
||||
|
||||
return pages.mapIndexed { i, jsonEl ->
|
||||
val page = if (jsonEl is JsonObject) {
|
||||
jsonEl.jsonObject["src"]!!.jsonPrimitive.content
|
||||
} else {
|
||||
jsonEl.jsonPrimitive.content
|
||||
}
|
||||
|
||||
Page(i, "", page)
|
||||
}
|
||||
}
|
||||
|
||||
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
|
||||
val jsonObj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val groups = jsonObj["groups"]!!.jsonObject
|
||||
val groupMap = groups.entries.associateBy({ it.value.jsonPrimitive.content.ifEmpty { "default" } }, { it.key })
|
||||
val chapterScanlator = chapter.scanlator ?: "default" // workaround for "" as group causing NullPointerException (#13772)
|
||||
|
||||
// prevent NullPointerException when chapters.key is 084 and chapter.chapter_number is 84
|
||||
val chapters = jsonObj["chapters"]!!.jsonObject.mapKeys {
|
||||
it.key.replace(Regex("^0+(?!$)"), "")
|
||||
}
|
||||
|
||||
val pages = if (chapters[chapter.chapter_number.toString()] != null) {
|
||||
chapters[chapter.chapter_number.toString()]!!
|
||||
.jsonObject["groups"]!!
|
||||
.jsonObject[groupMap[chapterScanlator]]!!
|
||||
.jsonArray
|
||||
} else {
|
||||
chapters[chapter.chapter_number.toInt().toString()]!!
|
||||
.jsonObject["groups"]!!
|
||||
.jsonObject[groupMap[chapterScanlator]]!!
|
||||
.jsonArray
|
||||
}
|
||||
|
||||
return pages.mapIndexed { i, jsonEl ->
|
||||
val page = if (jsonEl is JsonObject) {
|
||||
jsonEl.jsonObject["src"]!!.jsonPrimitive.content
|
||||
} else {
|
||||
jsonEl.jsonPrimitive.content
|
||||
}
|
||||
|
||||
Page(i, "", page)
|
||||
}
|
||||
}
|
||||
|
||||
// Stub
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PROXY_PREFIX) -> {
|
||||
val trimmedQuery = query.removePrefix(PROXY_PREFIX)
|
||||
// Only tag for recently read on search
|
||||
client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.TagInterceptor())
|
||||
.build()
|
||||
.newCall(proxySearchRequest(trimmedQuery))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
proxySearchParse(response, trimmedQuery)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.HomeInterceptor())
|
||||
.build()
|
||||
.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response, query)
|
||||
}
|
||||
.map { mangasPage ->
|
||||
require(mangasPage.mangas.isNotEmpty()) { SEARCH_FALLBACK_MSG }
|
||||
mangasPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
}
|
||||
|
||||
private fun proxySearchRequest(query: String): Request {
|
||||
try {
|
||||
val queryFragments = query.split("/")
|
||||
val source = queryFragments[0]
|
||||
val slug = queryFragments[1]
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
} catch (e: Exception) {
|
||||
throw Exception(SEARCH_FALLBACK_MSG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
throw Exception("Unused")
|
||||
}
|
||||
|
||||
private fun searchMangaParse(response: Response, query: String): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
|
||||
val filterList = result.asSequence()
|
||||
.map { it as JsonObject }
|
||||
.filter { it["title"].toString().contains(query.trim(), true) }
|
||||
.toList()
|
||||
|
||||
return parseMangaList(JsonArray(filterList), SortType.ALL)
|
||||
}
|
||||
|
||||
private fun proxySearchParse(response: Response, query: String): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
return parseSearchList(result, query)
|
||||
}
|
||||
|
||||
// ------------- Helpers and whatnot ---------------
|
||||
|
||||
private val volumeNotSpecifiedTerms = setOf("Uncategorized", "null", "")
|
||||
|
||||
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
|
||||
val jsonObj = json.parseToJsonElement(payload).jsonObject
|
||||
val groups = jsonObj["groups"]!!.jsonObject
|
||||
val chapters = jsonObj["chapters"]!!.jsonObject
|
||||
val seriesSlug = jsonObj["slug"]!!.jsonPrimitive.content
|
||||
|
||||
val seriesPrefs = Injekt.get<Application>().getSharedPreferences("source_${id}_updateTime:$seriesSlug", 0)
|
||||
val seriesPrefsEditor = seriesPrefs.edit()
|
||||
|
||||
val chapterList = chapters.entries.flatMap { chapterEntry ->
|
||||
val chapterNum = chapterEntry.key
|
||||
val chapterObj = chapterEntry.value.jsonObject
|
||||
val chapterGroups = chapterObj["groups"]!!.jsonObject
|
||||
val volume = chapterObj["volume"]!!.jsonPrimitive.content.let {
|
||||
if (volumeNotSpecifiedTerms.contains(it)) null else it
|
||||
}
|
||||
val title = chapterObj["title"]!!.jsonPrimitive.content
|
||||
|
||||
chapterGroups.entries.map { groupEntry ->
|
||||
val groupNum = groupEntry.key
|
||||
val releaseDate = chapterObj["release_date"]?.jsonObject?.get(groupNum)
|
||||
|
||||
SChapter.create().apply {
|
||||
scanlator = groups[groupNum]!!.jsonPrimitive.content
|
||||
chapter_number = chapterNum.toFloatOrNull() ?: -1f
|
||||
|
||||
date_upload = if (releaseDate != null) {
|
||||
releaseDate.jsonPrimitive.double.toLong() * 1000
|
||||
} else {
|
||||
val currentTimeMillis = System.currentTimeMillis()
|
||||
|
||||
if (!seriesPrefs.contains(chapterNum)) {
|
||||
seriesPrefsEditor.putLong(chapterNum, currentTimeMillis)
|
||||
}
|
||||
|
||||
seriesPrefs.getLong(chapterNum, currentTimeMillis)
|
||||
}
|
||||
|
||||
name = buildString {
|
||||
if (!volume.isNullOrBlank()) append("Vol.$volume ")
|
||||
append("Ch.$chapterNum")
|
||||
if (title.isNotBlank()) append(" - $title")
|
||||
}
|
||||
|
||||
url = if (chapterGroups[groupNum] is JsonArray) {
|
||||
"${manga.url}/$chapterNum/$groupNum"
|
||||
} else {
|
||||
chapterGroups[groupNum]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seriesPrefsEditor.apply()
|
||||
|
||||
return chapterList.sortedByDescending { it.chapter_number }
|
||||
}
|
||||
|
||||
private fun parseMangaList(payload: JsonArray, sortType: SortType): MangasPage {
|
||||
val mangaList = payload.mapNotNull { jsonEl ->
|
||||
val jsonObj = jsonEl.jsonObject
|
||||
val pinned = jsonObj["pinned"]!!.jsonPrimitive.boolean
|
||||
|
||||
if (sortType == SortType.PINNED && pinned) {
|
||||
parseManga(jsonObj)
|
||||
} else if (sortType == SortType.UNPINNED && !pinned) {
|
||||
parseManga(jsonObj)
|
||||
} else if (sortType == SortType.ALL) {
|
||||
parseManga(jsonObj)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangaList, false)
|
||||
}
|
||||
|
||||
private fun parseSearchList(payload: JsonObject, query: String): MangasPage {
|
||||
val tempManga = SManga.create().apply {
|
||||
url = "/read/$query"
|
||||
}
|
||||
|
||||
val mangaList = listOf(parseManga(payload, tempManga))
|
||||
|
||||
return MangasPage(mangaList, false)
|
||||
}
|
||||
|
||||
private fun parseManga(jsonObj: JsonObject, mangaReference: SManga? = null): SManga =
|
||||
SManga.create().apply {
|
||||
title = jsonObj["title"]!!.jsonPrimitive.content
|
||||
artist = jsonObj["artist"]?.jsonPrimitive?.content ?: ARTIST_FALLBACK
|
||||
author = jsonObj["author"]?.jsonPrimitive?.content ?: AUTHOR_FALLBACK
|
||||
|
||||
val descriptionFull = jsonObj["description"]?.jsonPrimitive?.content
|
||||
description = descriptionFull?.substringBefore("Tags: ") ?: DESCRIPTION_FALLBACK
|
||||
genre = descriptionFull?.let {
|
||||
if (it.contains("Tags: ")) {
|
||||
it.substringAfter("Tags: ")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
url = mangaReference?.url ?: jsonObj["url"]!!.jsonPrimitive.content
|
||||
thumbnail_url = jsonObj["coverUrl"]?.jsonPrimitive?.content
|
||||
?: jsonObj["cover"]?.jsonPrimitive?.content ?: ""
|
||||
}
|
||||
|
||||
// ----------------- Things we aren't supporting -----------------
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw Exception("imageUrlParse not supported.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROXY_PREFIX = "cubari:"
|
||||
const val AUTHOR_FALLBACK = "Unknown"
|
||||
const val ARTIST_FALLBACK = "Unknown"
|
||||
const val DESCRIPTION_FALLBACK = "No description."
|
||||
const val SEARCH_FALLBACK_MSG = "Unable to parse. Is your query in the format of $PROXY_PREFIX<source>/<slug>?"
|
||||
|
||||
enum class SortType {
|
||||
PINNED,
|
||||
UNPINNED,
|
||||
ALL,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class CubariFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
Cubari("en"),
|
||||
Cubari("all"),
|
||||
Cubari("other"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
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 CubariUrlActivity : 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 = with(host) {
|
||||
when {
|
||||
equals("m.imgur.com") || equals("imgur.com") -> fromSource("imgur", pathSegments)
|
||||
equals("m.reddit.com") || equals("reddit.com") || equals("www.reddit.com") -> fromSource("reddit", pathSegments)
|
||||
equals("imgchest.com") -> fromSource("imgchest", pathSegments)
|
||||
equals("catbox.moe") || equals("www.catbox.moe") -> fromSource("catbox", pathSegments)
|
||||
else -> fromCubari(pathSegments)
|
||||
}
|
||||
}
|
||||
|
||||
if (query == null) {
|
||||
Log.e("CubariUrlActivity", "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("CubariUrlActivity", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun fromSource(source: String, pathSegments: List<String>): String? {
|
||||
if (pathSegments.size >= 2) {
|
||||
val id = pathSegments[1]
|
||||
|
||||
return "${Cubari.PROXY_PREFIX}$source/$id"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fromCubari(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 3) {
|
||||
val source = pathSegments[1]
|
||||
val slug = pathSegments[2]
|
||||
"${Cubari.PROXY_PREFIX}$source/$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RemoteStorageUtils {
|
||||
abstract class GenericInterceptor(private val transparent: Boolean) : Interceptor {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
abstract val jsScript: String
|
||||
|
||||
abstract fun urlModifier(originalUrl: String): String
|
||||
|
||||
internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") {
|
||||
@JavascriptInterface
|
||||
fun passPayload(passedPayload: String) {
|
||||
payload = passedPayload
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
try {
|
||||
val originalRequest = chain.request()
|
||||
val originalResponse = chain.proceed(originalRequest)
|
||||
return proceedWithWebView(originalRequest, originalResponse)
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
|
||||
private fun proceedWithWebView(request: Request, response: Response): Response {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webView: WebView? = null
|
||||
|
||||
val origRequestUrl = request.url.toString()
|
||||
val headers = request.headers.toMultimap().mapValues {
|
||||
it.value.getOrNull(0) ?: ""
|
||||
}.toMutableMap()
|
||||
val jsInterface = JsInterface(latch)
|
||||
|
||||
handler.post {
|
||||
val webview = WebView(Injekt.get<Application>())
|
||||
webView = webview
|
||||
with(webview.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = false
|
||||
loadWithOverviewMode = false
|
||||
userAgentString = request.header("User-Agent")
|
||||
}
|
||||
|
||||
webview.addJavascriptInterface(jsInterface, "android")
|
||||
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
view.evaluateJavascript(jsScript) {}
|
||||
if (transparent) {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview.loadUrl(urlModifier(origRequestUrl), headers)
|
||||
}
|
||||
|
||||
latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)
|
||||
|
||||
handler.postDelayed(
|
||||
{ webView?.destroy() },
|
||||
DELAY_MILLIS * (if (transparent) 2 else 1),
|
||||
)
|
||||
|
||||
return if (transparent) {
|
||||
response
|
||||
} else {
|
||||
response.newBuilder().body(jsInterface.payload.toResponseBody(response.body.contentType())).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TagInterceptor : GenericInterceptor(true) {
|
||||
override val jsScript: String = """
|
||||
let dispatched = false;
|
||||
window.addEventListener('history-ready', function () {
|
||||
if (!dispatched) {
|
||||
dispatched = true;
|
||||
Promise.all(
|
||||
[globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
|
||||
).then(e => {
|
||||
window.android.passPayload(JSON.stringify(e.flatMap(e => e)))
|
||||
});
|
||||
}
|
||||
});
|
||||
tag();
|
||||
"""
|
||||
|
||||
override fun urlModifier(originalUrl: String): String {
|
||||
return originalUrl.replace("/api/", "/").replace("/series/", "/")
|
||||
}
|
||||
}
|
||||
|
||||
class HomeInterceptor : GenericInterceptor(false) {
|
||||
override val jsScript: String = """
|
||||
let dispatched = false;
|
||||
(function () {
|
||||
if (!dispatched) {
|
||||
dispatched = true;
|
||||
Promise.all(
|
||||
[globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
|
||||
).then(e => {
|
||||
window.android.passPayload(JSON.stringify(e.flatMap(e => e) ) )
|
||||
});
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
override fun urlModifier(originalUrl: String): String {
|
||||
return originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_SEC: Long = 10
|
||||
const val DELAY_MILLIS: Long = 10000
|
||||
}
|
||||
}
|
||||
2
src/all/danbooru/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
13
src/all/danbooru/build.gradle
Normal file
@@ -0,0 +1,13 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Danbooru'
|
||||
pkgNameSuffix = 'all.danbooru'
|
||||
extClass = '.Danbooru'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/danbooru/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/all/danbooru/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/all/danbooru/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src/all/danbooru/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src/all/danbooru/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |