Cubari extension. (#6311)

This commit is contained in:
funkyhippo 2021-03-27 11:53:17 -07:00 committed by GitHub
parent 434db9fe35
commit 24f1b6f96e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 633 additions and 0 deletions

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.extension">
<application>
<activity
android:name=".all.cubari.CubariUrlActivity"
android:excludeFromRecents="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"
android:pathPattern="/read/..*"
android:scheme="https" />
<data
android:host="*cubari.moe"
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"
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"
android:pathPattern="/a/..*"
android:scheme="https" />
<data
android:host="*imgur.com"
android:pathPattern="/gallery/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Cubari'
pkgNameSuffix = "all.cubari"
extClass = '.Cubari'
extVersionCode = 1
libVersion = '1.2'
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,353 @@
package eu.kanade.tachiyomi.extension.all.cubari
import android.os.Build
import eu.kanade.tachiyomi.extension.BuildConfig
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
import rx.Observable
open class Cubari : HttpSource() {
final override val name = "Cubari"
final override val baseUrl = "https://cubari.moe"
final override val supportsLatest = true
final override val lang = "all"
override fun headersBuilder() = Headers.Builder().apply {
add(
"User-Agent",
"(Android ${Build.VERSION.RELEASE}; " +
"${Build.MANUFACTURER} ${Build.MODEL}) " +
"Tachiyomi/${BuildConfig.VERSION_NAME} " +
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 {
return parseMangaList(JSONArray(response.body()!!.string()), 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 {
return parseMangaList(JSONArray(response.body()!!.string()), 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 {
return parseMangaFromApi(JSONObject(response.body()!!.string()), manga)
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response -> chapterListParse(response, manga) }
}
// Gets the chapter list based on the series being viewed
override fun chapterListRequest(manga: SManga): Request {
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 -> {
var 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 = JSONArray(res)
val pageArray = ArrayList<Page>()
for (i in 0 until pages.length()) {
val page = if (pages.optJSONObject(i) != null) {
pages.getJSONObject(i).getString("src")
} else {
pages[i]
}
pageArray.add(Page(i + 1, "", page.toString()))
}
return pageArray
}
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
val res = response.body()!!.string()
val json = JSONObject(res)
val groups = json.getJSONObject("groups")
val groupIter = groups.keys()
val groupMap = HashMap<String, String>()
while (groupIter.hasNext()) {
val groupKey = groupIter.next()
groupMap[groups.getString(groupKey)] = groupKey
}
val chapters = json.getJSONObject("chapters")
val pages = if (chapters.has(chapter.chapter_number.toString())) {
chapters
.getJSONObject(chapter.chapter_number.toString())
.getJSONObject("groups")
.getJSONArray(groupMap[chapter.scanlator])
} else {
chapters
.getJSONObject(chapter.chapter_number.toInt().toString())
.getJSONObject("groups")
.getJSONArray(groupMap[chapter.scanlator])
}
val pageArray = ArrayList<Page>()
for (i in 0 until pages.length()) {
val page = if (pages.optJSONObject(i) != null) {
pages.getJSONObject(i).getString("src")
} else {
pages[i]
}
pageArray.add(Page(i + 1, "", page.toString()))
}
return pageArray
}
// 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(searchMangaRequest(page, trimmedQuery, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response, trimmedQuery)
}
}
else -> Observable.just(MangasPage(ArrayList(), false))
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): 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("Unable to parse. Is your query in the format of ${Cubari.PROXY_PREFIX}<source>/<slug>?")
}
}
override fun searchMangaParse(response: Response): MangasPage {
throw Exception("Unused")
}
private fun searchMangaParse(response: Response, query: String): MangasPage {
return parseSearchList(JSONObject(response.body()!!.string()), query)
}
// ------------- Helpers and whatnot ---------------
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
val json = JSONObject(payload)
val groups = json.getJSONObject("groups")
val chapters = json.getJSONObject("chapters")
val chapterList = ArrayList<SChapter>()
val iter = chapters.keys()
while (iter.hasNext()) {
val chapterNum = iter.next()
val chapterObj = chapters.getJSONObject(chapterNum)
val chapterGroups = chapterObj.getJSONObject("groups")
val groupsIter = chapterGroups.keys()
while (groupsIter.hasNext()) {
val groupNum = groupsIter.next()
val chapter = SChapter.create()
chapter.scanlator = groups.getString(groupNum)
if (chapterObj.has("release_date")) {
chapter.date_upload =
chapterObj.getJSONObject("release_date").getLong(groupNum) * 1000
}
chapter.name = chapterNum + " - " + chapterObj.getString("title")
chapter.chapter_number = chapterNum.toFloat()
chapter.url =
if (chapterGroups.optJSONArray(groupNum) != null) {
"${manga.url}/$chapterNum/$groupNum"
} else {
chapterGroups.getString(groupNum)
}
chapterList.add(chapter)
}
}
return chapterList.reversed()
}
private fun parseMangaList(payload: JSONArray, sortType: SortType): MangasPage {
val mangas = ArrayList<SManga>()
for (i in 0 until payload.length()) {
val json = payload.getJSONObject(i)
val pinned = json.getBoolean("pinned")
if (sortType == SortType.PINNED && pinned) {
mangas.add(parseMangaFromRemoteStorage(json))
} else if (sortType == SortType.UNPINNED && !pinned) {
mangas.add(parseMangaFromRemoteStorage(json))
}
}
return MangasPage(mangas, false)
}
private fun parseSearchList(payload: JSONObject, query: String): MangasPage {
val mangas = ArrayList<SManga>()
val tempManga = SManga.create()
tempManga.url = "/read/$query"
mangas.add(parseMangaFromApi(payload, tempManga))
return MangasPage(mangas, false)
}
private fun parseMangaFromRemoteStorage(json: JSONObject): SManga {
val manga = SManga.create()
manga.title = json.getString("title")
manga.artist = json.optString("artist", ARTIST_FALLBACK)
manga.author = json.optString("author", AUTHOR_FALLBACK)
manga.description = json.optString("description", DESCRIPTION_FALLBACK)
manga.url = json.getString("url")
manga.thumbnail_url = json.getString("coverUrl")
return manga
}
private fun parseMangaFromApi(json: JSONObject, mangaReference: SManga): SManga {
val manga = SManga.create()
manga.title = json.getString("title")
manga.artist = json.optString("artist", ARTIST_FALLBACK)
manga.author = json.optString("author", AUTHOR_FALLBACK)
manga.description = json.optString("description", DESCRIPTION_FALLBACK)
manga.url = mangaReference.url
manga.thumbnail_url = json.optString("cover", "")
return manga
}
// ----------------- 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."
enum class SortType {
PINNED,
UNPINNED
}
}
}

View File

@ -0,0 +1,64 @@
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 = when (host) {
"m.imgur.com", "imgur.com" -> fromImgur(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 fromImgur(pathSegments: List<String>): String? {
if (pathSegments.size >= 2) {
val id = pathSegments[1]
return "${Cubari.PROXY_PREFIX}imgur/$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
}
}
}

View File

@ -0,0 +1,147 @@
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
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) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
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(ResponseBody.create(response.body()?.contentType(), jsInterface.payload)).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
}
}