Compare commits
234 Commits
2f2671757e
...
d9df2955e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9df2955e9 | ||
|
|
9dbe2bd9ac | ||
|
|
ac21e59f0d | ||
|
|
d97622117c | ||
|
|
d3e7c52053 | ||
|
|
64b3c18350 | ||
|
|
eec57bbf65 | ||
|
|
e595780f24 | ||
|
|
e17d796fc1 | ||
|
|
8bd7ed9a90 | ||
|
|
a085a3cb0d | ||
|
|
4c6b86973c | ||
|
|
0bfc8ce9ae | ||
|
|
948ff10002 | ||
|
|
fdc8e29671 | ||
|
|
709609fb70 | ||
|
|
8c230e25c3 | ||
|
|
df3319f128 | ||
|
|
2f9626a2f7 | ||
|
|
b1ee9c1589 | ||
|
|
f28161c28b | ||
|
|
6eae846dc8 | ||
|
|
f59060dc92 | ||
|
|
1ed99e7967 | ||
|
|
7bf99f4bd0 | ||
|
|
1e768ace94 | ||
|
|
6bced1de64 | ||
|
|
ef51432e8a | ||
|
|
fc81248cb2 | ||
|
|
c35f9f6111 | ||
|
|
d8f1be1bb4 | ||
|
|
4dd6a75e10 | ||
|
|
1a0a2fa4bf | ||
|
|
43a4da5a3b | ||
|
|
cc1631633f | ||
|
|
b30df94837 | ||
|
|
c0dfbb7c13 | ||
|
|
60e78280c8 | ||
|
|
1aa1119924 | ||
|
|
f9e9cc66c8 | ||
|
|
afed002091 | ||
|
|
cc885e7353 | ||
|
|
f17d40a1c1 | ||
|
|
b617a1a28b | ||
|
|
1251cbb432 | ||
|
|
2a7a3f0e2b | ||
|
|
55ae73a0d6 | ||
|
|
3946637e7b | ||
|
|
b8154e7698 | ||
|
|
9194e31208 | ||
|
|
68af18e453 | ||
|
|
bd530edb7d | ||
|
|
7b11937c24 | ||
|
|
84b9ea4ce6 | ||
|
|
75b19b7006 | ||
|
|
12ea764882 | ||
|
|
e54c91bb88 | ||
|
|
14d8020d97 | ||
|
|
809848ab33 | ||
|
|
5578344e19 | ||
|
|
4c73cc5e75 | ||
|
|
64cdf418ce | ||
|
|
5e88baecd1 | ||
|
|
afbb0796d9 | ||
|
|
3c22c12ad7 | ||
|
|
151718b605 | ||
|
|
91115ac93f | ||
|
|
f817f7b049 | ||
|
|
6bf6da4db8 | ||
|
|
9128e25848 | ||
|
|
68ab4021dd | ||
|
|
2dac04f01b | ||
|
|
243f4529c6 | ||
|
|
399540fda3 | ||
|
|
8367f924ad | ||
|
|
a913bed9af | ||
|
|
25a4651aa5 | ||
|
|
4c7f5d6a37 | ||
|
|
9f7b11fc57 | ||
|
|
84383a1601 | ||
|
|
58fda2ef51 | ||
|
|
d7444787be | ||
|
|
8f13e4185c | ||
|
|
399f44d219 | ||
|
|
b8f9a38f48 | ||
|
|
678b988f13 | ||
|
|
d8bef0a1af | ||
|
|
b26e4829de | ||
|
|
05817f38c2 | ||
|
|
68b70d54d9 | ||
|
|
9627718a40 | ||
|
|
cd528cde6a | ||
|
|
a2d9686eeb | ||
|
|
3b8be5fd2c | ||
|
|
dc20771476 | ||
|
|
db1d8e26f4 | ||
|
|
9f720a3488 | ||
|
|
f019c2a273 | ||
|
|
5f6e00499a | ||
|
|
f5429b887f | ||
|
|
6071f598f4 | ||
|
|
eab806d5b1 | ||
|
|
cf7446489d | ||
|
|
546696c0d2 | ||
|
|
9b206fc092 | ||
|
|
afbbe6991f | ||
|
|
2b394c8c38 | ||
|
|
5a004d08f5 | ||
|
|
775d39331e | ||
|
|
77b4ea1261 | ||
|
|
0aca7c467c | ||
|
|
b4b8bbe748 | ||
|
|
7e5b58bb5c | ||
|
|
67e224826a | ||
|
|
59f72d823b | ||
|
|
3aa18a35a2 | ||
|
|
975216f40d | ||
|
|
7d754490b8 | ||
|
|
284d807f53 | ||
|
|
a98f720ff3 | ||
|
|
89f33e0106 | ||
|
|
3af84ded97 | ||
|
|
3968208d9c | ||
|
|
fe130d5aa8 | ||
|
|
8b0be67685 | ||
|
|
6824183cf1 | ||
|
|
ad03299e49 | ||
|
|
fa899de7d9 | ||
|
|
80a5052273 | ||
|
|
141b80aa19 | ||
|
|
99dde3ca4d | ||
|
|
6cf479516f | ||
|
|
551f9a032f | ||
|
|
8c34cc1b5b | ||
|
|
b6f6b46a4f | ||
|
|
156f67ad7e | ||
|
|
3404731442 | ||
|
|
bacd296e4e | ||
|
|
adb0b65b64 | ||
|
|
3cb189632d | ||
|
|
d2878c5670 | ||
|
|
e92d5d1819 | ||
|
|
7a61751a50 | ||
|
|
d1bed69ada | ||
|
|
652a56fdc6 | ||
|
|
9c74df6069 | ||
|
|
007d231fa7 | ||
|
|
a78b2624da | ||
|
|
d7241034bc | ||
|
|
16073b38cb | ||
|
|
880b04047e | ||
|
|
e3449dfb65 | ||
|
|
89c380c808 | ||
|
|
cbaf26bf4d | ||
|
|
49a970fde8 | ||
|
|
145dc251e6 | ||
|
|
979ae7f53f | ||
|
|
f85c52aa38 | ||
|
|
039c241d33 | ||
|
|
3afb33fe4a | ||
|
|
fc9dc6c4f2 | ||
|
|
f5bc644071 | ||
|
|
b838d8b34b | ||
|
|
24d5d8920b | ||
|
|
4007461062 | ||
|
|
9f9cf36a20 | ||
|
|
5997e5507e | ||
|
|
9aabbfdd82 | ||
|
|
83731ebae6 | ||
|
|
9b874ae720 | ||
|
|
b38f8bd0f5 | ||
|
|
4e8f7db34f | ||
|
|
a493a38e6e | ||
|
|
7377d6427b | ||
|
|
4ac7d3559c | ||
|
|
ccbde23c1f | ||
|
|
9282d65dd8 | ||
|
|
7112ffb937 | ||
|
|
b0bfe86ca2 | ||
|
|
38732723a6 | ||
|
|
fe5d96f4c5 | ||
|
|
914c609279 | ||
|
|
805fa1f631 | ||
|
|
93f2816776 | ||
|
|
322ef36ab0 | ||
|
|
a8daf1f8ec | ||
|
|
08ead06187 | ||
|
|
0e218bbfac | ||
|
|
07c8fd1080 | ||
|
|
f26545a8e8 | ||
|
|
85d3008fb8 | ||
|
|
f2b1f92502 | ||
|
|
ada0565967 | ||
|
|
2bc7dad385 | ||
|
|
a4c4090301 | ||
|
|
5ef2e36997 | ||
|
|
92a0ada358 | ||
|
|
ab4b1e2276 | ||
|
|
107e060ddc | ||
|
|
e114e5a257 | ||
|
|
0ac4b785aa | ||
|
|
30226abee7 | ||
|
|
22f0293165 | ||
|
|
a7cfba60a2 | ||
|
|
50ae4f3f06 | ||
|
|
2928fc45a6 | ||
|
|
6a29aa1afd | ||
|
|
d4d2785853 | ||
|
|
0c8a27b820 | ||
|
|
3b0eb9a789 | ||
|
|
562983bca3 | ||
|
|
23e386a7e5 | ||
|
|
00046adbdc | ||
|
|
9d05af7e2d | ||
|
|
948decf018 | ||
|
|
e533814cc9 | ||
|
|
e7e9bc349d | ||
|
|
1b0b8e103f | ||
|
|
7071d503a7 | ||
|
|
7b44fec7db | ||
|
|
dd6f475a01 | ||
|
|
233df400f0 | ||
|
|
eb6fe726df | ||
|
|
0aac33c331 | ||
|
|
dbb77d4a12 | ||
|
|
577ac38ac7 | ||
|
|
7f05502c60 | ||
|
|
11e61df10a | ||
|
|
f945d11a8f | ||
|
|
e4cb04df6c | ||
|
|
6968ba5e19 | ||
|
|
97ab81e855 | ||
|
|
64d08b63ee | ||
|
|
258874dc70 |
108
CONTRIBUTING.md
@ -10,31 +10,54 @@ or fixing it directly by submitting a Pull Request.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
1. [Tools](#tools)
|
||||
2. [Cloning the repository](#cloning-the-repository)
|
||||
2. [Getting help](#getting-help)
|
||||
3. [Writing an extension](#writing-an-extension)
|
||||
1. [Setting up a new Gradle module](#setting-up-a-new-gradle-module)
|
||||
2. [Core dependencies](#core-dependencies)
|
||||
3. [Extension main class](#extension-main-class)
|
||||
4. [Extension call flow](#extension-call-flow)
|
||||
5. [Misc notes](#misc-notes)
|
||||
6. [Advanced extension features](#advanced-extension-features)
|
||||
4. [Multi-source themes](#multi-source-themes)
|
||||
1. [The directory structure](#the-directory-structure)
|
||||
2. [Development workflow](#development-workflow)
|
||||
3. [Scaffolding overrides](#scaffolding-overrides)
|
||||
4. [Additional Notes](#additional-notes)
|
||||
5. [Running](#running)
|
||||
6. [Debugging](#debugging)
|
||||
1. [Android Debugger](#android-debugger)
|
||||
2. [Logs](#logs)
|
||||
3. [Inspecting network calls](#inspecting-network-calls)
|
||||
4. [Using external network inspecting tools](#using-external-network-inspecting-tools)
|
||||
7. [Building](#building)
|
||||
8. [Submitting the changes](#submitting-the-changes)
|
||||
1. [Pull Request checklist](#pull-request-checklist)
|
||||
- [Contributing](#contributing)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Tools](#tools)
|
||||
- [Cloning the repository](#cloning-the-repository)
|
||||
- [Getting help](#getting-help)
|
||||
- [Writing an extension](#writing-an-extension)
|
||||
- [Setting up a new Gradle module](#setting-up-a-new-gradle-module)
|
||||
- [Loading a subset of Gradle modules](#loading-a-subset-of-gradle-modules)
|
||||
- [Extension file structure](#extension-file-structure)
|
||||
- [AndroidManifest.xml (optional)](#androidmanifestxml-optional)
|
||||
- [build.gradle](#buildgradle)
|
||||
- [Core dependencies](#core-dependencies)
|
||||
- [Extension API](#extension-api)
|
||||
- [DataImage library](#dataimage-library)
|
||||
- [i18n library](#i18n-library)
|
||||
- [Additional dependencies](#additional-dependencies)
|
||||
- [Extension main class](#extension-main-class)
|
||||
- [Main class key variables](#main-class-key-variables)
|
||||
- [Extension call flow](#extension-call-flow)
|
||||
- [Popular Manga](#popular-manga)
|
||||
- [Latest Manga](#latest-manga)
|
||||
- [Manga Search](#manga-search)
|
||||
- [Filters](#filters)
|
||||
- [Manga Details](#manga-details)
|
||||
- [Chapter](#chapter)
|
||||
- [Chapter Pages](#chapter-pages)
|
||||
- [Misc notes](#misc-notes)
|
||||
- [Advanced Extension features](#advanced-extension-features)
|
||||
- [URL intent filter](#url-intent-filter)
|
||||
- [Update strategy](#update-strategy)
|
||||
- [Renaming existing sources](#renaming-existing-sources)
|
||||
- [Multi-source themes](#multi-source-themes)
|
||||
- [The directory structure](#the-directory-structure)
|
||||
- [Development workflow](#development-workflow)
|
||||
- [Scaffolding overrides](#scaffolding-overrides)
|
||||
- [Additional Notes](#additional-notes)
|
||||
- [Running](#running)
|
||||
- [Debugging](#debugging)
|
||||
- [Android Debugger](#android-debugger)
|
||||
- [Logs](#logs)
|
||||
- [Inspecting network calls](#inspecting-network-calls)
|
||||
- [Using external network inspecting tools](#using-external-network-inspecting-tools)
|
||||
- [Setup your proxy server](#setup-your-proxy-server)
|
||||
- [OkHttp proxy setup](#okhttp-proxy-setup)
|
||||
- [Building](#building)
|
||||
- [Submitting the changes](#submitting-the-changes)
|
||||
- [Pull Request checklist](#pull-request-checklist)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -692,43 +715,46 @@ with open(f"{package}/src/{source}.kt", "w") as f:
|
||||
|
||||
## Running
|
||||
|
||||
To make local development more convenient, you can use the following run configuration to launch
|
||||
Tachiyomi directly at the Browse panel:
|
||||
For local development, use the following run configuration to launch the app directly into the Browse panel.
|
||||
|
||||

|
||||

|
||||
|
||||
If you're running a Preview or debug build of Tachiyomi:
|
||||
Copy the following into `Launch Flags` for the Debug build of Mihon:
|
||||
|
||||
```
|
||||
-W -S -n eu.kanade.tachiyomi.debug/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
|
||||
-W -S -n app.mihon.dev/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
|
||||
```
|
||||
|
||||
And for a release build of Tachiyomi:
|
||||
For other builds, replace `app.mihon.dev` with the corresponding package IDs:
|
||||
- Release build: `app.mihon`
|
||||
- Preview build: `app.mihon.debug`
|
||||
|
||||
```
|
||||
-W -S -n eu.kanade.tachiyomi/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
|
||||
```
|
||||
If the extension builds and runs successfully then the code changes should be ready to test in your local app.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you're deploying to Android 11 or higher, enable the "Always install with package manager" option in the run configurations. Without this option enabled, you might face issues such as Android Studio running an older version of the extension without the modifications you might have done.
|
||||
> If you're deploying to Android 11 or higher, enable the `Always install with package manager` option in the run configurations. Without this option enabled, you might face issues such as Android Studio running an older version of the extension without the modifications you might have done.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Android Debugger
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you didn't build the main app from source with debug enabled and are using a release/beta APK, you **need** a rooted device.
|
||||
> If you are using an emulator instead, make sure you choose a profile **without** Google Play.
|
||||
> If you didn't **build the main app** from source with **debug enabled** and are using a release/beta APK, you **need a rooted device**.
|
||||
> If you are using an **emulator** instead, make sure you choose a profile **without Google Play**.
|
||||
|
||||
You can leverage the Android Debugger to step through your extension while debugging.
|
||||
Follow the steps above for building and running locally if you haven't already. Debugging will not work if you did not follow the steps above.
|
||||
|
||||
You can leverage the Android Debugger to add breakpoints and step through your extension while debugging.
|
||||
|
||||
You *cannot* simply use Android Studio's `Debug 'module.name'` -> this will most likely result in an
|
||||
error while launching.
|
||||
|
||||
Instead, once you've built and installed your extension on the target device, use
|
||||
`Attach Debugger to Android Process` to start debugging Tachiyomi.
|
||||
Instead, once you've built and installed your extension on the target device, use
|
||||
`Attach Debugger to Android Process` to start debugging the app.
|
||||
|
||||

|
||||
Inside the `Attach Debugger to Android Process` window, once the app is running on your device and `Show all processes` is checked, you should be able to select `app.mihon.dev` and press OK.
|
||||
|
||||

|
||||
|
||||
|
||||
### Logs
|
||||
|
||||
@ -18,6 +18,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
implementation(project(":core"))
|
||||
}
|
||||
|
||||
tasks.register("printDependentExtensions") {
|
||||
|
||||
@ -15,12 +15,30 @@ val jsonInstance: Json by injectLazy()
|
||||
inline fun <reified T> String.parseAs(json: Json = jsonInstance): T =
|
||||
json.decodeFromString(this)
|
||||
|
||||
/**
|
||||
* Parses JSON string into an object of type [T], applying a [transform] function to the string before parsing.
|
||||
*
|
||||
* @param json The [Json] instance to use for deserialization.
|
||||
* @param transform A function to transform the original JSON string before it is parsed.
|
||||
*/
|
||||
inline fun <reified T> String.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
|
||||
transform(this).parseAs(json)
|
||||
|
||||
/**
|
||||
* Parses the response body into an object of type [T].
|
||||
*/
|
||||
inline fun <reified T> Response.parseAs(json: Json = jsonInstance): T =
|
||||
use { json.decodeFromStream(body.byteStream()) }
|
||||
|
||||
/**
|
||||
* Parses the response body into an object of type [T], applying a transformation to the raw JSON string before parsing.
|
||||
*
|
||||
* @param json The [Json] instance to use for parsing. Defaults to the injected instance.
|
||||
* @param transform A function to transform the JSON string before it's decoded.
|
||||
*/
|
||||
inline fun <reified T> Response.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
|
||||
body.string().parseAs(json, transform)
|
||||
|
||||
/**
|
||||
* Serializes the object to a JSON string.
|
||||
*/
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 9
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:speedbinb"))
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comicgamma
|
||||
|
||||
import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbInterceptor
|
||||
import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbReader
|
||||
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.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
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
|
||||
|
||||
open class ComicGamma(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "ja",
|
||||
) : ParsedHttpSource() {
|
||||
override val supportsLatest = false
|
||||
|
||||
private val json = Injekt.get<Json>()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(SpeedBinbInterceptor(json))
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga/", headers)
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
override fun popularMangaSelector() = ".tab_panel.active .manga_item"
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
url = element.selectFirst(Evaluator.Tag("a"))!!.attr("href")
|
||||
title = element.selectFirst(Evaluator.Class("manga_title"))!!.text()
|
||||
author = element.selectFirst(Evaluator.Class("manga_author"))!!.text()
|
||||
val genreList = element.select(Evaluator.Tag("li")).map { it.text() }
|
||||
genre = genreList.joinToString()
|
||||
status = when {
|
||||
genreList.contains("完結") && !genreList.contains("リピート配信") -> SManga.COMPLETED
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
thumbnail_url = element.selectFirst(Evaluator.Tag("img"))!!.absUrl("src")
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
fetchPopularManga(page).map { p -> MangasPage(p.mangas.filter { it.title.contains(query) }, false) }
|
||||
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException()
|
||||
override fun searchMangaSelector() = throw UnsupportedOperationException()
|
||||
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
private val reader by lazy { SpeedBinbReader(client, headers, json) }
|
||||
|
||||
override fun pageListParse(document: Document) = reader.pageListParse(document)
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val titleElement = document.selectFirst(Evaluator.Class("manga__title"))!!
|
||||
val titleName = titleElement.child(0).text()
|
||||
val desc = document.selectFirst(".detail__item > p:not(:empty)")?.run {
|
||||
select(Evaluator.Tag("br")).prepend("\\n")
|
||||
this.text().replace("\\n", "\n").replace("\n ", "\n")
|
||||
}
|
||||
val listResponse = client.newCall(popularMangaRequest(0)).execute()
|
||||
val manga = popularMangaParse(listResponse).mangas.find { it.title == titleName }
|
||||
return manga?.apply { description = desc } ?: SManga.create().apply {
|
||||
author = titleElement.child(1).text()
|
||||
description = desc
|
||||
status = SManga.UNKNOWN
|
||||
val slug = document.location().removeSuffix("/").substringAfterLast("/")
|
||||
thumbnail_url = "$baseUrl/img/manga_thumb/${slug}_list.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".read__area .read__outer > a:not([href=#comics])"
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
url = element.attr("href").toOldChapterUrl()
|
||||
val number = url.removeSuffix("/").substringAfterLast('/').replace('_', '.')
|
||||
val list = element.selectFirst(Evaluator.Class("read__contents"))!!.children()
|
||||
name = "[$number] ${list[0].text()}"
|
||||
if (list.size >= 3) {
|
||||
date_upload = dateFormat.parseJST(list[2].text())?.time ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) =
|
||||
GET(baseUrl + chapter.url.toNewChapterUrl(), headers)
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||
|
||||
companion object {
|
||||
internal fun SimpleDateFormat.parseJST(date: String) = parse(date)?.apply {
|
||||
time += 12 * 3600 * 1000 // updates at 12 noon
|
||||
}
|
||||
|
||||
private fun getJSTFormat(datePattern: String) =
|
||||
SimpleDateFormat(datePattern, Locale.JAPANESE).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT+09:00")
|
||||
}
|
||||
|
||||
private val dateFormat by lazy { getJSTFormat("yyyy年M月dd日") }
|
||||
|
||||
private fun String.toOldChapterUrl(): String {
|
||||
// ../../../_files/madeinabyss/063_2/
|
||||
val segments = split('/')
|
||||
val size = segments.size
|
||||
val slug = segments[size - 3]
|
||||
val number = segments[size - 2]
|
||||
return "/manga/$slug/_files/$number/"
|
||||
}
|
||||
|
||||
private fun String.toNewChapterUrl(): String {
|
||||
val segments = split('/')
|
||||
return "/_files/${segments[2]}/${segments[4]}/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
||||
baseVersionCode = 1
|
||||
BIN
lib-multisrc/comiciviewer/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
lib-multisrc/comiciviewer/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
lib-multisrc/comiciviewer/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
lib-multisrc/comiciviewer/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
lib-multisrc/comiciviewer/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,244 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comiciviewer
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
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 keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
abstract class ComiciViewer(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ConfigurableSource, HttpSource() {
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.JAPAN)
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = super.client.newBuilder()
|
||||
.addInterceptor(ImageInterceptor())
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ranking/manga", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("div.ranking-box-vertical, div.ranking-box-vertical-top3").map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
|
||||
title = element.selectFirst(".title-text")!!.text()
|
||||
thumbnail_url = element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/category/manga", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("div.category-box-vertical").map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
|
||||
title = element.selectFirst(".title-text")!!.text()
|
||||
thumbnail_url = element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotBlank()) {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("keyword", query)
|
||||
.addQueryParameter("page", (page - 1).toString())
|
||||
.addQueryParameter("filter", "series")
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val browseFilter = filterList.firstInstance<BrowseFilter>()
|
||||
val pathAndQuery = getFilterOptions()[browseFilter.state].second
|
||||
val url = (baseUrl + pathAndQuery).toHttpUrl().newBuilder().build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val url = response.request.url.toString()
|
||||
|
||||
return when {
|
||||
url.contains("/ranking/") -> popularMangaParse(response)
|
||||
url.contains("/category/") -> latestUpdatesParse(response)
|
||||
|
||||
else -> {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("div.manga-store-item").map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(
|
||||
element.selectFirst("a.c-ms-clk-article")!!.attr("href"),
|
||||
)
|
||||
title = element.selectFirst("h2.manga-title")!!.text()
|
||||
thumbnail_url =
|
||||
element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")
|
||||
?.let { "https:$it" }
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst("li.mode-paging-active + li > a") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
return SManga.create().apply {
|
||||
title = document.select("h1.series-h-title span").last()!!.text()
|
||||
author = document.select("div.series-h-credit-user").text()
|
||||
artist = author
|
||||
description = document.selectFirst("div.series-h-credit-info-text-text")?.text()
|
||||
genre = document.select("a.series-h-tag-link").joinToString { it.text().removePrefix("#") }
|
||||
thumbnail_url = document.selectFirst("div.series-h-img source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET(baseUrl + manga.url + "/list?s=1", headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val showLocked = preferences.getBoolean(SHOW_LOCKED_PREF_KEY, true)
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select("div.series-ep-list-item").mapNotNull { element ->
|
||||
val link = element.selectFirst("a.g-episode-link-wrapper")!!
|
||||
|
||||
val isFree = element.selectFirst("span.free-icon-new") != null
|
||||
val isTicketLocked = element.selectFirst("img[data-src*='free_charge_ja.svg']") != null
|
||||
val isCoinLocked = element.selectFirst("img[data-src*='coin.svg']") != null
|
||||
val isLocked = !isFree
|
||||
|
||||
if (!showLocked && isLocked) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
SChapter.create().apply {
|
||||
val chapterUrl = link.attr("data-href")
|
||||
if (chapterUrl.isNotEmpty()) {
|
||||
setUrlWithoutDomain(chapterUrl)
|
||||
} else {
|
||||
url = response.request.url.toString() + "#" + link.attr("data-article") + DUMMY_URL_SUFFIX
|
||||
}
|
||||
|
||||
name = link.selectFirst("span.series-ep-list-item-h-text")!!.text()
|
||||
when {
|
||||
isTicketLocked -> name = "🔒 $name"
|
||||
isCoinLocked -> name = "\uD83E\uDE99 $name"
|
||||
}
|
||||
|
||||
date_upload = dateFormat.tryParse(element.selectFirst("time")?.attr("datetime"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) {
|
||||
throw Exception("Log in via WebView to read purchased chapters and refresh the entry")
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val viewer = document.selectFirst("#comici-viewer") ?: throw Exception("You need to log in via WebView to read this chapter or purchase this chapter")
|
||||
val comiciViewerId = viewer.attr("comici-viewer-id")
|
||||
val memberJwt = viewer.attr("data-member-jwt")
|
||||
val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("comici-viewer-id", comiciViewerId)
|
||||
.addQueryParameter("user-id", memberJwt)
|
||||
.addQueryParameter("page-from", "0")
|
||||
|
||||
val pageTo = client.newCall(GET(requestUrl.addQueryParameter("page-to", "1").build(), headers))
|
||||
.execute().use { initialResponse ->
|
||||
if (!initialResponse.isSuccessful) {
|
||||
throw Exception("Failed to get page list")
|
||||
}
|
||||
initialResponse.parseAs<ViewerResponse>().totalPages.toString()
|
||||
}
|
||||
|
||||
val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build()
|
||||
return client.newCall(GET(getAllPagesUrl, headers)).execute().use { allPagesResponse ->
|
||||
if (allPagesResponse.isSuccessful) {
|
||||
allPagesResponse.parseAs<ViewerResponse>().result.map { resultItem ->
|
||||
val urlBuilder = resultItem.imageUrl.toHttpUrl().newBuilder()
|
||||
if (resultItem.scramble.isNotEmpty()) {
|
||||
urlBuilder.addQueryParameter("scramble", resultItem.scramble)
|
||||
}
|
||||
Page(
|
||||
index = resultItem.sort,
|
||||
imageUrl = urlBuilder.build().toString(),
|
||||
)
|
||||
}.sortedBy { it.index }
|
||||
} else {
|
||||
throw Exception("Failed to get full page list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_LOCKED_PREF_KEY
|
||||
title = "Show locked chapters"
|
||||
setDefaultValue(true)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
protected open class BrowseFilter(vals: Array<String>) : Filter.Select<String>("Filter by", vals)
|
||||
|
||||
protected open fun getFilterOptions(): List<Pair<String, String>> = listOf(
|
||||
Pair("ランキング", "/ranking/manga"),
|
||||
Pair("読み切り", "/category/manga?type=読み切り"),
|
||||
Pair("完結", "/category/manga?type=完結"),
|
||||
Pair("月曜日", "/category/manga?type=連載中&day=月"),
|
||||
Pair("火曜日", "/category/manga?type=連載中&day=火"),
|
||||
Pair("水曜日", "/category/manga?type=連載中&day=水"),
|
||||
Pair("木曜日", "/category/manga?type=連載中&day=木"),
|
||||
Pair("金曜日", "/category/manga?type=連載中&day=金"),
|
||||
Pair("土曜日", "/category/manga?type=連載中&day=土"),
|
||||
Pair("日曜日", "/category/manga?type=連載中&day=日"),
|
||||
Pair("その他", "/category/manga?type=連載中&day=その他"),
|
||||
)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
BrowseFilter(getFilterOptions().map { it.first }.toTypedArray()),
|
||||
)
|
||||
|
||||
// Unsupported
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
companion object {
|
||||
private const val SHOW_LOCKED_PREF_KEY = "pref_show_locked_chapters"
|
||||
private const val DUMMY_URL_SUFFIX = "NeedLogin"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package eu.kanade.tachiyomi.multisrc.comiciviewer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class ViewerResponse(
|
||||
val result: List<PageDto>,
|
||||
val totalPages: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val imageUrl: String,
|
||||
val scramble: String,
|
||||
val sort: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class TilePos(
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
)
|
||||
@ -1,4 +1,4 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
package eu.kanade.tachiyomi.multisrc.comiciviewer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@ -10,28 +10,37 @@ import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object ImageDescrambler {
|
||||
class ImageInterceptor : Interceptor {
|
||||
|
||||
// Left-top corner position
|
||||
private class TilePos(val x: Int, val y: Int)
|
||||
|
||||
/**
|
||||
* Interceptor to descramble the image.
|
||||
*/
|
||||
fun interceptor(chain: Interceptor.Chain): Response {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val scrambleData = request.url.queryParameter("scramble")
|
||||
|
||||
if (scrambleData.isNullOrEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val newUrl = request.url.newBuilder()
|
||||
.removeAllQueryParameters("scramble")
|
||||
.build()
|
||||
val newRequest = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val scramble = request.url.fragment ?: return response // return if no scramble fragment
|
||||
val tiles = buildList {
|
||||
scramble.split("-").forEachIndexed { index, s ->
|
||||
val scrambleInt = s.toInt()
|
||||
add(index, TilePos(scrambleInt / 4, scrambleInt % 4))
|
||||
scrambleData.drop(1).dropLast(1).replace(" ", "").split(",").forEach {
|
||||
val scrambleInt = it.toInt()
|
||||
add(TilePos(scrambleInt / 4, scrambleInt % 4))
|
||||
}
|
||||
}
|
||||
|
||||
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
|
||||
val descrambledImg = drawDescrambledImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
|
||||
val descrambledImg =
|
||||
unscrambleImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
@ -41,20 +50,27 @@ object ImageDescrambler {
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun drawDescrambledImage(rawImage: Bitmap, width: Int, height: Int, tiles: List<TilePos>): Bitmap {
|
||||
// Prepare canvas
|
||||
private fun unscrambleImage(
|
||||
rawImage: Bitmap,
|
||||
width: Int,
|
||||
height: Int,
|
||||
tiles: List<TilePos>,
|
||||
): Bitmap {
|
||||
val descrambledImg = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(descrambledImg)
|
||||
|
||||
// Tile width and height(4x4)
|
||||
val tileWidth = width / 4
|
||||
val tileHeight = height / 4
|
||||
|
||||
// Draw rect
|
||||
var count = 0
|
||||
for (x in 0..3) {
|
||||
for (y in 0..3) {
|
||||
val desRect = Rect(x * tileWidth, y * tileHeight, (x + 1) * tileWidth, (y + 1) * tileHeight)
|
||||
val desRect = Rect(
|
||||
x * tileWidth,
|
||||
y * tileHeight,
|
||||
(x + 1) * tileWidth,
|
||||
(y + 1) * tileHeight,
|
||||
)
|
||||
val srcRect = Rect(
|
||||
tiles[count].x * tileWidth,
|
||||
tiles[count].y * tileHeight,
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 5
|
||||
baseVersionCode = 6
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.multisrc.greenshit
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
@ -31,8 +30,6 @@ import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.io.IOException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
abstract class GreenShit(
|
||||
override val name: String,
|
||||
@ -196,31 +193,11 @@ abstract class GreenShit(
|
||||
private fun pageListRequestMobile(chapter: SChapter): Request {
|
||||
val pathSegment = chapter.url.replace("capitulo", "capitulo-app-token")
|
||||
val newHeaders = headers.newBuilder()
|
||||
.set("x-client-hash", generateToken(scanId, SECRET_KEY))
|
||||
.set("authorization", "Bearer $token")
|
||||
.build()
|
||||
return GET("$apiUrl$pathSegment", newHeaders)
|
||||
}
|
||||
|
||||
private fun generateToken(scanId: Long, secretKey: String): String {
|
||||
val timestamp = System.currentTimeMillis() / 1000
|
||||
val expiration = timestamp + 3600
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("scan_id", scanId)
|
||||
put("timestamp", timestamp)
|
||||
put("exp", expiration)
|
||||
}.toJsonString()
|
||||
|
||||
val hmac = Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
|
||||
hmac.init(secretKeySpec)
|
||||
val signatureBytes = hmac.doFinal(payload.toByteArray())
|
||||
val signature = signatureBytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return Base64.encodeToString("$payload.$signature".toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> pageListParseMobile(response)
|
||||
@ -444,7 +421,5 @@ abstract class GreenShit(
|
||||
private const val TOKEN_PREF = "greenShitToken"
|
||||
private const val USERNAME_PREF = "usernamePref"
|
||||
private const val PASSWORD_PREF = "passwordPref"
|
||||
|
||||
private const val SECRET_KEY = "sua_chave_secreta_aqui_32_caracteres"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 3
|
||||
baseVersionCode = 4
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
@file:Suppress("PrivatePropertyName", "PropertyName")
|
||||
|
||||
package eu.kanade.tachiyomi.multisrc.hentaihand
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Created by ipcjs on 2025/9/23.
|
||||
*/
|
||||
@Serializable
|
||||
class ResponseDto<T>(
|
||||
val data: T,
|
||||
val next_page_url: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class LoginResponseDto(val auth: AuthDto) {
|
||||
@Serializable
|
||||
class AuthDto(val access_token: String)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class PageListResponseDto(val images: List<PageDto>) {
|
||||
fun toPageList() = images.map { Page(it.page, "", it.source_url) }
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val page: Int,
|
||||
val source_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
typealias ChapterListResponseDto = List<ChapterDto>
|
||||
typealias ChapterResponseDto = ChapterDto
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(
|
||||
private val slug: String,
|
||||
private val name: String?,
|
||||
private val added_at: String?,
|
||||
private val updated_at: String?,
|
||||
) {
|
||||
companion object {
|
||||
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
}
|
||||
|
||||
private fun parseDate(date: String?): Long =
|
||||
if (date == null) {
|
||||
0
|
||||
} else if (date.contains("day")) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
|
||||
}.timeInMillis
|
||||
} else {
|
||||
DATE_FORMAT.parse(date)?.time ?: 0
|
||||
}
|
||||
|
||||
fun toSChapter(slug: String) = SChapter.create().also { chapter ->
|
||||
chapter.url = "$slug/${this.slug}"
|
||||
chapter.name = name ?: "Chapter"
|
||||
chapter.date_upload = parseDate(added_at)
|
||||
}
|
||||
|
||||
fun toSChapter() = SChapter.create().also { chapter ->
|
||||
chapter.url = slug
|
||||
chapter.name = "Chapter"
|
||||
chapter.date_upload = parseDate(updated_at)
|
||||
chapter.chapter_number = 1f
|
||||
}
|
||||
}
|
||||
|
||||
typealias MangaDetailsResponseDto = MangaDto
|
||||
|
||||
@Serializable
|
||||
class MangaDto(
|
||||
private val slug: String,
|
||||
private val title: String,
|
||||
private val image_url: String?,
|
||||
private val artists: List<NameDto>?,
|
||||
private val authors: List<NameDto>?,
|
||||
private val tags: List<NameDto>?,
|
||||
private val relationships: List<NameDto>?,
|
||||
private val status: String?,
|
||||
private val alternative_title: String?,
|
||||
private val groups: List<NameDto>?,
|
||||
private val description: String?,
|
||||
private val pages: Int?,
|
||||
private val category: NameDto?,
|
||||
private val language: NameDto?,
|
||||
private val parodies: List<NameDto>?,
|
||||
private val characters: List<NameDto>?,
|
||||
) {
|
||||
fun toSManga() = SManga.create().also { manga ->
|
||||
manga.url = slug.prependIndent("/en/comic/")
|
||||
manga.title = title
|
||||
manga.thumbnail_url = image_url
|
||||
}
|
||||
|
||||
fun toSMangaDetails() = toSManga().also { manga ->
|
||||
manga.artist = artists?.toNames()
|
||||
manga.author = authors?.toNames() ?: manga.artist
|
||||
manga.genre = listOfNotNull(tags, relationships).flatten().toNames()
|
||||
manga.status = when (status) {
|
||||
"complete" -> SManga.COMPLETED
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"onhold" -> SManga.ONGOING
|
||||
"canceled" -> SManga.COMPLETED
|
||||
else -> SManga.COMPLETED
|
||||
}
|
||||
manga.description = listOf(
|
||||
Pair("Alternative Title", alternative_title),
|
||||
Pair("Groups", groups?.toNames()),
|
||||
Pair("Description", description),
|
||||
Pair("Pages", pages?.toString()),
|
||||
Pair("Category", category?.name),
|
||||
Pair("Language", language?.name),
|
||||
Pair("Parodies", parodies?.toNames()),
|
||||
Pair("Characters", characters?.toNames()),
|
||||
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class NameDto(val name: String)
|
||||
|
||||
fun List<NameDto>.toNames() = if (this.isEmpty()) null else this.joinToString { it.name }
|
||||
|
||||
@Serializable
|
||||
class IdDto(val id: String)
|
||||
@ -16,13 +16,8 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import keiyoushi.utils.parseAs
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
@ -32,10 +27,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
abstract class HentaiHand(
|
||||
@ -48,32 +41,15 @@ abstract class HentaiHand(
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private fun slugToUrl(json: JsonObject) = json["slug"]!!.jsonPrimitive.content.prependIndent("/en/comic/")
|
||||
|
||||
private fun jsonArrayToString(arrayKey: String, obj: JsonObject): String? {
|
||||
val array = obj[arrayKey]!!.jsonArray
|
||||
if (array.isEmpty()) return null
|
||||
return array.joinToString(", ") {
|
||||
it.jsonObject["name"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val jsonResponse = json.parseToJsonElement(response.body.string())
|
||||
val mangaList = jsonResponse.jsonObject["data"]!!.jsonArray.map {
|
||||
val obj = it.jsonObject
|
||||
SManga.create().apply {
|
||||
url = slugToUrl(obj)
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
val hasNextPage = jsonResponse.jsonObject["next_page_url"]!!.jsonPrimitive.content.isNotEmpty()
|
||||
return MangasPage(mangaList, hasNextPage)
|
||||
val resp = response.parseAs<ResponseDto<List<MangaDto>>>()
|
||||
val hasNextPage = !resp.next_page_url.isNullOrEmpty()
|
||||
return MangasPage(resp.data.map { it.toSManga() }, hasNextPage)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
@ -116,9 +92,7 @@ abstract class HentaiHand(
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { response ->
|
||||
// Returns the first matched id, or null if there are no results
|
||||
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map {
|
||||
it.jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
val idList = response.parseAs<ResponseDto<List<IdDto>>>().data.map { it.id }
|
||||
if (idList.isEmpty()) {
|
||||
return@map null
|
||||
} else {
|
||||
@ -177,33 +151,7 @@ abstract class HentaiHand(
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val obj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
return SManga.create().apply {
|
||||
url = slugToUrl(obj)
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
|
||||
artist = jsonArrayToString("artists", obj)
|
||||
author = jsonArrayToString("authors", obj) ?: artist
|
||||
genre = listOfNotNull(jsonArrayToString("tags", obj), jsonArrayToString("relationships", obj)).joinToString(", ")
|
||||
status = when (obj["status"]!!.jsonPrimitive.content) {
|
||||
"complete" -> SManga.COMPLETED
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"onhold" -> SManga.ONGOING
|
||||
"canceled" -> SManga.COMPLETED
|
||||
else -> SManga.COMPLETED
|
||||
}
|
||||
|
||||
description = listOf(
|
||||
Pair("Alternative Title", obj["alternative_title"]!!.jsonPrimitive.content),
|
||||
Pair("Groups", jsonArrayToString("groups", obj)),
|
||||
Pair("Description", obj["description"]!!.jsonPrimitive.content),
|
||||
Pair("Pages", obj["pages"]!!.jsonPrimitive.content),
|
||||
Pair("Category", try { obj["category"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
|
||||
Pair("Language", try { obj["language"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
|
||||
Pair("Parodies", jsonArrayToString("parodies", obj)),
|
||||
Pair("Characters", jsonArrayToString("characters", obj)),
|
||||
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
|
||||
}
|
||||
return response.parseAs<MangaDetailsResponseDto>().toSMangaDetails()
|
||||
}
|
||||
|
||||
// Chapters
|
||||
@ -220,40 +168,13 @@ abstract class HentaiHand(
|
||||
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val slug = response.request.url.toString().substringAfter("/api/comics/").removeSuffix("/chapters")
|
||||
return if (chapters) {
|
||||
val array = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
array.map {
|
||||
SChapter.create().apply {
|
||||
url = "$slug/${it.jsonObject["slug"]!!.jsonPrimitive.content}"
|
||||
name = it.jsonObject["name"]!!.jsonPrimitive.content
|
||||
val date = it.jsonObject["added_at"]!!.jsonPrimitive.content
|
||||
date_upload = if (date.contains("day")) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
|
||||
}.timeInMillis
|
||||
} else {
|
||||
DATE_FORMAT.parse(it.jsonObject["added_at"]!!.jsonPrimitive.content)?.time ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (this.chapters) {
|
||||
val slug = response.request.url.toString()
|
||||
.substringAfter("/api/comics/")
|
||||
.removeSuffix("/chapters")
|
||||
response.parseAs<ChapterListResponseDto>().map { it.toSChapter(slug) }
|
||||
} else {
|
||||
val obj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
url = obj["slug"]!!.jsonPrimitive.content
|
||||
name = "Chapter"
|
||||
val date = obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content
|
||||
date_upload = if (date.contains("day")) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
|
||||
}.timeInMillis
|
||||
} else {
|
||||
DATE_FORMAT.parse(obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content)?.time ?: 0
|
||||
}
|
||||
chapter_number = 1f
|
||||
},
|
||||
)
|
||||
listOf(response.parseAs<ChapterResponseDto>().toSChapter())
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,13 +185,7 @@ abstract class HentaiHand(
|
||||
return GET("$baseUrl/api/comics/$slug/images")
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
json.parseToJsonElement(response.body.string()).jsonObject["images"]!!.jsonArray.map {
|
||||
val imgObj = it.jsonObject
|
||||
val index = imgObj["page"]!!.jsonPrimitive.int
|
||||
val imgUrl = imgObj["source_url"]!!.jsonPrimitive.content
|
||||
Page(index, "", imgUrl)
|
||||
}
|
||||
override fun pageListParse(response: Response): List<Page> = response.parseAs<PageListResponseDto>().toPageList()
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
|
||||
@ -278,7 +193,10 @@ abstract class HentaiHand(
|
||||
|
||||
protected fun authIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (username.isEmpty() or password.isEmpty()) {
|
||||
if (username.isEmpty() or password.isEmpty()
|
||||
// image request doesn't need token
|
||||
or !request.url.toString().startsWith(baseUrl)
|
||||
) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
@ -304,7 +222,7 @@ abstract class HentaiHand(
|
||||
}
|
||||
try {
|
||||
// Returns access token as a string, unless unparseable
|
||||
return json.parseToJsonElement(response.body.string()).jsonObject["auth"]!!.jsonObject["access-token"]!!.jsonPrimitive.content
|
||||
return response.parseAs<LoginResponseDto>().auth.access_token
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw IOException("Cannot parse login response body")
|
||||
}
|
||||
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 10
|
||||
baseVersionCode = 11
|
||||
|
||||
@ -107,7 +107,7 @@ class Chapter(
|
||||
fun isLocked() = (isLocked == true) || (isTimeLocked == true)
|
||||
|
||||
fun toSChapter(mangaSlug: String?) = SChapter.create().apply {
|
||||
val prefix = if (isLocked()) "🔒 " else ""
|
||||
val prefix = if (!isAccessible()) "🔒 " else ""
|
||||
val seriesSlug = mangaSlug ?: mangaPost.slug
|
||||
url = "/series/$seriesSlug/$slug#$id"
|
||||
name = "${prefix}Chapter $number"
|
||||
|
||||
@ -57,10 +57,12 @@ abstract class Iken(
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers)
|
||||
|
||||
protected open val popularMangaSelector = "aside a:has(img), .splide:has(.card) li a:has(img)"
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val entries = document.select("aside a:has(img)").mapNotNull {
|
||||
val entries = document.select(popularMangaSelector).mapNotNull {
|
||||
titleCache[it.absUrl("href").substringAfter("series/")]?.toSManga()
|
||||
}
|
||||
|
||||
@ -171,7 +173,7 @@ abstract class Iken(
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = showLockedChapterPrefKey
|
||||
title = "Show locked chapters"
|
||||
title = "Show inaccessible chapters"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 17
|
||||
baseVersionCode = 18
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:i18n"))
|
||||
|
||||
@ -215,7 +215,9 @@ abstract class Keyoapp(
|
||||
protected open val statusSelector: String = "div:has(span:containsOwn(Status)) ~ div"
|
||||
protected open val authorSelector: String = "div:has(span:containsOwn(Author)) ~ div"
|
||||
protected open val artistSelector: String = "div:has(span:containsOwn(Artist)) ~ div"
|
||||
protected open val genreSelector: String = "div:has(span:containsOwn(Type)) ~ div"
|
||||
protected open val genreSelector: String = "div.grid:has(>h1) > div > a:not([title='Status'])"
|
||||
|
||||
protected open val typeSelector: String = "div:has(span:containsOwn(Type)) ~ div"
|
||||
protected open val dateSelector: String = ".text-xs"
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
@ -226,16 +228,16 @@ abstract class Keyoapp(
|
||||
author = document.selectFirst(authorSelector)?.text()
|
||||
artist = document.selectFirst(artistSelector)?.text()
|
||||
genre = buildList {
|
||||
document.selectFirst(genreSelector)?.text()?.replaceFirstChar {
|
||||
document.selectFirst(typeSelector)?.text()?.replaceFirstChar {
|
||||
if (it.isLowerCase()) {
|
||||
it.titlecase(
|
||||
Locale.getDefault(),
|
||||
Locale.ENGLISH,
|
||||
)
|
||||
} else {
|
||||
it.toString()
|
||||
}
|
||||
}.let(::add)
|
||||
document.select("div.grid:has(>h1) > div > a").forEach { add(it.text()) }
|
||||
document.select(genreSelector).forEach { add(it.text()) }
|
||||
}.joinToString()
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 44
|
||||
baseVersionCode = 45
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:cryptoaes"))
|
||||
|
||||
@ -919,7 +919,7 @@ abstract class Madara(
|
||||
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
WordSet("hace").startsWith(date) -> {
|
||||
WordSet("hace", "giờ", "phút", "giây").startsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
// Handle "jour" with a number before it
|
||||
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 19
|
||||
baseVersionCode = 20
|
||||
|
||||
@ -141,7 +141,7 @@ abstract class MadTheme(
|
||||
title = element.selectFirst("a")!!.attr("title")
|
||||
element.selectFirst(".summary")?.text()?.let { description = it }
|
||||
element.select(".genres > *").joinToString { it.text() }.takeIf { it.isNotEmpty() }?.let { genre = it }
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src")
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src") + "#image-request"
|
||||
}
|
||||
|
||||
/*
|
||||
@ -155,7 +155,7 @@ abstract class MadTheme(
|
||||
title = document.selectFirst(".detail h1")!!.text()
|
||||
author = document.select(".detail .meta > p > strong:contains(Authors) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||
genre = document.select(".detail .meta > p > strong:contains(Genres) ~ a").joinToString { it.text().trim(',', ' ') }
|
||||
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src")
|
||||
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src") + "#image-request"
|
||||
|
||||
val altNames = document.selectFirst(".detail h2")?.text()
|
||||
?.split(',', ';')
|
||||
|
||||
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 8
|
||||
baseVersionCode = 9
|
||||
|
||||
@ -211,7 +211,7 @@ abstract class MangaBox(
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, .list-comic-item-wrap .list-story-item"
|
||||
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, div.list-comic-item-wrap"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
|
||||
|
||||
|
||||
9
lib-multisrc/natsuid/build.gradle.kts
Normal file
@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.toJsonString
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.parser.Parser
|
||||
|
||||
@Serializable
|
||||
class Term(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val taxonomy: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Manga(
|
||||
val id: Int,
|
||||
val slug: String,
|
||||
val title: Rendered,
|
||||
val content: Rendered,
|
||||
@SerialName("_embedded")
|
||||
val embedded: Embedded,
|
||||
) {
|
||||
fun toSManga(appendId: Boolean = false) = SManga.create().apply {
|
||||
url = MangaUrl(id, slug).toJsonString()
|
||||
title = Parser.unescapeEntities(this@Manga.title.rendered, false)
|
||||
description = buildString {
|
||||
append(Jsoup.parseBodyFragment(content.rendered).wholeText())
|
||||
if (appendId) {
|
||||
append("\n\nID: $id")
|
||||
}
|
||||
}
|
||||
thumbnail_url = embedded.featuredMedia.firstOrNull()?.sourceUrl
|
||||
author = embedded.getTerms("series-author").joinToString()
|
||||
artist = embedded.getTerms("artist").joinToString()
|
||||
genre = buildSet {
|
||||
addAll(embedded.getTerms("genre"))
|
||||
addAll(embedded.getTerms("type"))
|
||||
}.joinToString()
|
||||
status = with(embedded.getTerms("status")) {
|
||||
when {
|
||||
contains("Ongoing") -> SManga.ONGOING
|
||||
contains("Completed") -> SManga.COMPLETED
|
||||
contains("Cancelled") -> SManga.CANCELLED
|
||||
contains("On Hiatus") -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Embedded(
|
||||
@SerialName("wp:featuredmedia")
|
||||
val featuredMedia: List<FeaturedMedia>,
|
||||
@SerialName("wp:term")
|
||||
private val terms: List<List<Term>>,
|
||||
) {
|
||||
fun getTerms(type: String): List<String> {
|
||||
return terms.find { it.getOrNull(0)?.taxonomy == type }?.map { it.name } ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class FeaturedMedia(
|
||||
@SerialName("source_url")
|
||||
val sourceUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Rendered(
|
||||
val rendered: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MangaUrl(
|
||||
val id: Int,
|
||||
val slug: String,
|
||||
)
|
||||
@ -0,0 +1,103 @@
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
abstract class SelectFilter<T>(
|
||||
name: String,
|
||||
private val options: List<Pair<String, T>>,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
options.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
val selected get() = options[state].second
|
||||
}
|
||||
|
||||
class CheckBoxFilter<T>(name: String, val value: T) : Filter.CheckBox(name)
|
||||
|
||||
abstract class CheckBoxGroup<T>(
|
||||
name: String,
|
||||
options: List<Pair<String, T>>,
|
||||
) : Filter.Group<CheckBoxFilter<T>>(
|
||||
name,
|
||||
options.map { CheckBoxFilter(it.first, it.second) },
|
||||
) {
|
||||
val checked get() = state.filter { it.state }.map { it.value }
|
||||
}
|
||||
|
||||
class TriStateFilter<T>(name: String, val value: T) : Filter.TriState(name)
|
||||
|
||||
abstract class TriStateGroupFilter<T>(
|
||||
name: String,
|
||||
options: List<Pair<String, T>>,
|
||||
) : Filter.Group<TriStateFilter<T>>(
|
||||
name,
|
||||
options.map { TriStateFilter(it.first, it.second) },
|
||||
) {
|
||||
val included get() = state.filter { it.isIncluded() }.map { it.value }
|
||||
val excluded get() = state.filter { it.isExcluded() }.map { it.value }
|
||||
}
|
||||
|
||||
class SortFilter(
|
||||
selection: Int = 0,
|
||||
) : Filter.Sort(
|
||||
name = "Sort",
|
||||
values = sortBy.map { it.first }.toTypedArray(),
|
||||
state = Selection(selection, false),
|
||||
) {
|
||||
val sort get() = sortBy[state?.index ?: 0].second
|
||||
val isAscending get() = state?.ascending ?: false
|
||||
|
||||
companion object {
|
||||
private val sortBy = listOf(
|
||||
"Popular" to "popular",
|
||||
"Rating" to "rating",
|
||||
"Updated" to "updated",
|
||||
"Bookmarked" to "bookmarked",
|
||||
"Title" to "title",
|
||||
)
|
||||
|
||||
val popular = FilterList(SortFilter(0))
|
||||
val latest = FilterList(SortFilter(2))
|
||||
}
|
||||
}
|
||||
|
||||
class GenreFilter(
|
||||
genres: List<Pair<String, String>>,
|
||||
) : TriStateGroupFilter<String>("Genre", genres)
|
||||
|
||||
class GenreInclusion : SelectFilter<String>(
|
||||
name = "Genre Inclusion Mode",
|
||||
options = listOf(
|
||||
"OR" to "OR",
|
||||
"AND" to "AND",
|
||||
),
|
||||
)
|
||||
|
||||
class GenreExclusion : SelectFilter<String>(
|
||||
name = "Genre Exclusion Mode",
|
||||
options = listOf(
|
||||
"OR" to "OR",
|
||||
"AND" to "AND",
|
||||
),
|
||||
)
|
||||
|
||||
class TypeFilter : CheckBoxGroup<String>(
|
||||
name = "Type",
|
||||
options = listOf(
|
||||
"Manga" to "manga",
|
||||
"Manhwa" to "manhwa",
|
||||
"Manhua" to "manhua",
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : CheckBoxGroup<String>(
|
||||
name = "Status",
|
||||
options = listOf(
|
||||
"Ongoing" to "ongoing",
|
||||
"Completed" to "completed",
|
||||
"Cancelled" to "cancelled",
|
||||
"On Hiatus" to "on-hiatus",
|
||||
"Unknown" to "unknown",
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,360 @@
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
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 keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.firstInstanceOrNull
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jsoup.Jsoup
|
||||
import rx.Observable
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
// https://themesinfo.com/natsu_id-theme-wordpress-c8x1c Wordpress Theme Author "Dzul Qurnain"
|
||||
abstract class NatsuId(
|
||||
override val name: String,
|
||||
override val lang: String,
|
||||
override val baseUrl: String,
|
||||
val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US),
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest: Boolean = true
|
||||
|
||||
protected open fun OkHttpClient.Builder.customizeClient(): OkHttpClient.Builder = this
|
||||
|
||||
final override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.customizeClient()
|
||||
// fix disk cache
|
||||
.apply {
|
||||
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
|
||||
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
searchMangaRequest(page, "", SortFilter.popular)
|
||||
|
||||
override fun popularMangaParse(response: Response) =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
searchMangaRequest(page, "", SortFilter.latest)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
searchMangaParse(response)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith("https://")) {
|
||||
deepLink(query)
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?action=advanced_search"
|
||||
val body = MultipartBody.Builder().apply {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("nonce", getNonce())
|
||||
filters.firstInstanceOrNull<GenreInclusion>()?.selected.also {
|
||||
addFormDataPart("inclusion", it ?: "OR")
|
||||
}
|
||||
filters.firstInstanceOrNull<GenreExclusion>()?.selected.also {
|
||||
addFormDataPart("exclusion", it ?: "OR")
|
||||
}
|
||||
addFormDataPart("page", page.toString())
|
||||
val genres = filters.firstInstanceOrNull<GenreFilter>()
|
||||
genres?.included.orEmpty().also {
|
||||
addFormDataPart("genre", it.toJsonString())
|
||||
}
|
||||
genres?.excluded.orEmpty().also {
|
||||
addFormDataPart("genre_exclude", it.toJsonString())
|
||||
}
|
||||
addFormDataPart("author", "[]")
|
||||
addFormDataPart("artist", "[]")
|
||||
addFormDataPart("project", "0")
|
||||
filters.firstInstanceOrNull<TypeFilter>()?.checked.orEmpty().also {
|
||||
addFormDataPart("type", it.toJsonString())
|
||||
}
|
||||
val sort = filters.firstInstance<SortFilter>()
|
||||
addFormDataPart("order", if (sort.isAscending) "asc" else "desc")
|
||||
addFormDataPart("orderby", sort.sort)
|
||||
addFormDataPart("query", query.trim())
|
||||
}.build()
|
||||
|
||||
return POST(url, headers, body)
|
||||
}
|
||||
|
||||
private var nonce: String? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getNonce(): String {
|
||||
if (nonce == null) {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?type=search_form&action=get_nonce"
|
||||
val response = client.newCall(GET(url, headers)).execute()
|
||||
|
||||
Jsoup.parseBodyFragment(response.body.string())
|
||||
.selectFirst("input[name=search_nonce]")
|
||||
?.attr("value")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.also {
|
||||
nonce = it
|
||||
}
|
||||
}
|
||||
|
||||
return nonce ?: throw Exception("Unable to get nonce")
|
||||
}
|
||||
|
||||
private val metadataClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
chain.proceed(chain.request()).newBuilder()
|
||||
.header("Cache-Control", "max-age=${24 * 60 * 60}")
|
||||
.removeHeader("Pragma")
|
||||
.removeHeader("Expires")
|
||||
.build()
|
||||
}.build()
|
||||
|
||||
override fun getFilterList() = runBlocking(Dispatchers.IO) {
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
SortFilter(),
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc"
|
||||
val response = metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_CACHE),
|
||||
).await()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_NETWORK),
|
||||
).enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.closeQuietly()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(name, "Failed to fetch genre filter", e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'reset' to load genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
val data = try {
|
||||
response.parseAs<List<Term>>(transform = ::transformJsonResponse)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(name, "Failed to parse genre filters", e)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Failed to parse genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
GenreFilter(
|
||||
data.map { it.name to it.slug },
|
||||
),
|
||||
GenreInclusion(),
|
||||
GenreInclusion(),
|
||||
),
|
||||
)
|
||||
|
||||
FilterList(filters)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
val slugs = document.select("div > a[href*=/manga/]:has(> img)").map {
|
||||
it.absUrl("href").toHttpUrl().pathSegments[1]
|
||||
}.ifEmpty {
|
||||
return MangasPage(emptyList(), false)
|
||||
}
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder().apply {
|
||||
slugs.forEach { slug ->
|
||||
addQueryParameter("slug[]", slug)
|
||||
}
|
||||
addQueryParameter("per_page", "${slugs.size + 1}")
|
||||
addQueryParameter("_embed", null)
|
||||
}.build()
|
||||
|
||||
val details = client.newCall(GET(url, headers)).execute()
|
||||
.parseAs<List<Manga>>(transform = ::transformJsonResponse)
|
||||
.filterNot { manga ->
|
||||
manga.embedded.getTerms("type").contains("Novel")
|
||||
}
|
||||
.associateBy { it.slug }
|
||||
|
||||
val mangas = slugs.mapNotNull { slug ->
|
||||
details[slug]?.toSManga()
|
||||
}
|
||||
|
||||
val hasNextPage = document.selectFirst("button:has(svg)") != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun deepLink(url: String): Observable<MangasPage> {
|
||||
val httpUrl = url.toHttpUrl()
|
||||
if (
|
||||
httpUrl.host == baseUrl.toHttpUrl().host &&
|
||||
httpUrl.pathSegments.size >= 2 &&
|
||||
httpUrl.pathSegments[0] == "manga"
|
||||
) {
|
||||
val slug = httpUrl.pathSegments[1]
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("slug[]", slug)
|
||||
.addQueryParameter("_embed", null)
|
||||
.build()
|
||||
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val manga = response.parseAs<List<Manga>>(transform = ::transformJsonResponse)[0]
|
||||
|
||||
if (manga.embedded.getTerms("type").contains("Novel")) {
|
||||
throw Exception("Novels are not supported")
|
||||
}
|
||||
|
||||
MangasPage(listOf(manga.toSManga()), false)
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Unsupported url"))
|
||||
}
|
||||
|
||||
private val descriptionIdRegex = Regex("""ID: (\d+)""")
|
||||
private fun getMangaId(manga: SManga): String {
|
||||
return if (manga.url.startsWith("{")) {
|
||||
manga.url.parseAs<MangaUrl>().id.toString()
|
||||
} else if (descriptionIdRegex.containsMatchIn(manga.description?.trim().orEmpty())) {
|
||||
descriptionIdRegex.find(manga.description!!.trim())!!.groupValues[1]
|
||||
} else {
|
||||
val document = client.newCall(
|
||||
GET(getMangaUrl(manga), headers),
|
||||
).execute().asJsoup()
|
||||
|
||||
document.selectFirst("#gallery-list")!!.attr("hx-get")
|
||||
.substringAfter("manga_id=").substringBefore("&")
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val id = getMangaId(manga)
|
||||
val appendId = !manga.url.startsWith("{")
|
||||
|
||||
return GET("$baseUrl/wp-json/wp/v2/manga/$id?_embed#$appendId", headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
val slug = if (manga.url.startsWith("{")) {
|
||||
manga.url.parseAs<MangaUrl>().slug
|
||||
} else {
|
||||
"$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
|
||||
}
|
||||
|
||||
return "$baseUrl/manga/$slug/"
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val manga = response.parseAs<Manga>(transform = ::transformJsonResponse)
|
||||
val appendId = response.request.url.fragment == "true"
|
||||
|
||||
return manga.toSManga(appendId)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = getMangaId(manga)
|
||||
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("manga_id", id)
|
||||
.addQueryParameter("page", "${Random.nextInt(99, 9999)}") // keep above 3 for loading hidden chapter
|
||||
.addQueryParameter("action", "chapter_list")
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
protected open val chapterListSelector = "div a:has(time)"
|
||||
protected open val chapterNameSelector = "span"
|
||||
protected open val chapterDateSelector = "time"
|
||||
protected open val chapterDateAttribute = "datetime"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
|
||||
return document.select(chapterListSelector).map {
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(it.absUrl("href"))
|
||||
name = it.selectFirst(chapterNameSelector)!!.ownText()
|
||||
date_upload = dateFormat.tryParse(
|
||||
it.selectFirst(chapterDateSelector)?.attr(chapterDateAttribute),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open val pageListSelector = "main .relative section > img"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select(pageListSelector).mapIndexed { idx, img ->
|
||||
Page(idx, imageUrl = img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
protected open fun transformJsonResponse(responseBody: String): String = responseBody
|
||||
}
|
||||
@ -1,275 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.otakusanctuary
|
||||
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.select.Elements
|
||||
import org.jsoup.select.Evaluator
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
open class OtakuSanctuary(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder().add("Referer", "$baseUrl/")
|
||||
|
||||
private val helper = OtakuSanctuaryHelper(lang)
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// There's no popular list, this will have to do
|
||||
override fun popularMangaRequest(page: Int) = POST(
|
||||
"$baseUrl/Manga/Newest",
|
||||
headers,
|
||||
FormBody.Builder().apply {
|
||||
add("Lang", helper.otakusanLang())
|
||||
add("Page", page.toString())
|
||||
add("Type", "Include")
|
||||
add("Dir", "NewPostedDate")
|
||||
}.build(),
|
||||
)
|
||||
|
||||
private fun parseMangaCollection(elements: Elements): List<SManga> {
|
||||
val page = emptyList<SManga>().toMutableList()
|
||||
|
||||
for (element in elements) {
|
||||
val url = element.select("div.mdl-card__title a").first()!!.attr("abs:href")
|
||||
// ignore external chapters
|
||||
if (url.toHttpUrl().host != baseUrl.toHttpUrl().host) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ignore web novels/light novels
|
||||
val variant = element.select("div.mdl-card__supporting-text div.text-overflow-90 a").text()
|
||||
if (variant.contains("Novel")) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ignore languages that dont match current ext
|
||||
val language = element.select("img.flag").attr("abs:src")
|
||||
.substringAfter("flags/")
|
||||
.substringBefore(".png")
|
||||
if (helper.otakusanLang() != "all" && language != helper.otakusanLang()) {
|
||||
continue
|
||||
}
|
||||
|
||||
page += SManga.create().apply {
|
||||
setUrlWithoutDomain(url)
|
||||
title = element.select("div.mdl-card__supporting-text a[target=_blank]").text()
|
||||
.replaceFirstChar { it.titlecase() }
|
||||
thumbnail_url = element.select("div.container-3-4.background-contain img").first()!!.attr("abs:src")
|
||||
}
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val collection = document.select("div.mdl-card")
|
||||
val hasNextPage = !document.select("button.btn-loadmore").text().contains("Hết")
|
||||
return MangasPage(parseMangaCollection(collection), hasNextPage)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
|
||||
GET(
|
||||
baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("Home/Search")
|
||||
addQueryParameter("search", query)
|
||||
}.build().toString(),
|
||||
headers,
|
||||
)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val collection = document.select("div.collection:has(.group-header:contains(Manga)) div.mdl-card")
|
||||
return MangasPage(parseMangaCollection(collection), false)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return SManga.create().apply {
|
||||
title = document.select("h1.title.text-lg-left.text-overflow-2-line")
|
||||
.text()
|
||||
.replaceFirstChar { it.titlecase() }
|
||||
author = document.select("tr:contains(Tác Giả) a.capitalize").first()!!.text()
|
||||
.replaceFirstChar { it.titlecase() }
|
||||
description = document.select("div.summary p").joinToString("\n") {
|
||||
it.run {
|
||||
select(Evaluator.Tag("br")).prepend("\\n")
|
||||
this.text().replace("\\n", "\n").replace("\n ", "\n")
|
||||
}
|
||||
}.trim()
|
||||
genre = document.select("div.genres a").joinToString { it.text() }
|
||||
thumbnail_url = document.select("div.container-3-4.background-contain img").attr("abs:src")
|
||||
|
||||
val statusString = document.select("tr:contains(Tình Trạng) td").first()!!.text().trim()
|
||||
status = when (statusString) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Done" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh")
|
||||
}
|
||||
|
||||
private fun parseDate(date: String): Long {
|
||||
if (date.contains("cách đây")) {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
date.contains("ngày") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||
date.contains("tiếng") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
date.contains("phút") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
date.contains("giây") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
else -> 0L
|
||||
}
|
||||
} else {
|
||||
return runCatching { dateFormat.parse(date)?.time }.getOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select("tr.chapter").map {
|
||||
val cells = it.select("td")
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(cells[1].select("a").attr("href"))
|
||||
name = cells[1].text()
|
||||
date_upload = parseDate(cells[3].text())
|
||||
chapter_number = cells[0].text().toFloatOrNull() ?: -1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val vi = document.select("#dataip").attr("value")
|
||||
val numericId = document.select("#inpit-c").attr("data-chapter-id")
|
||||
|
||||
val data = json.parseToJsonElement(
|
||||
client.newCall(
|
||||
POST(
|
||||
"$baseUrl/Manga/UpdateView",
|
||||
headers,
|
||||
FormBody.Builder().add("chapId", numericId).build(),
|
||||
),
|
||||
).execute().body.string(),
|
||||
).jsonObject
|
||||
|
||||
if (data["view"] != null) {
|
||||
val usingservers = mutableListOf(0, 0, 0)
|
||||
|
||||
val isSuccess = data["isSuccess"]!!.jsonArray.map { it.jsonPrimitive.content }
|
||||
return json.parseToJsonElement(data["view"]!!.jsonPrimitive.content).jsonArray.mapIndexed { idx, it ->
|
||||
var url = helper.processUrl(it.jsonPrimitive.content).removePrefix("image:")
|
||||
val indexServer = getIndexLessServer(usingservers)
|
||||
|
||||
if (url.contains("ImageSyncing") || url.contains("FetchService") || url.contains("otakusan.net_") && (url.contains("extendContent") || url.contains("/Extend")) && !url.contains("fetcher.otakusan.net") && !url.contains("image3.otakusan.net") && !url.contains("image3.otakuscan.net") && !url.contains("[GDP]") && !url.contains("[GDT]")) {
|
||||
if (url.startsWith("/api/Value/")) {
|
||||
val serverUrl = if (helper.otakusanLang() == "us" && indexServer == 1) {
|
||||
US_SERVERS[0]
|
||||
} else {
|
||||
SERVERS[indexServer]
|
||||
}
|
||||
url = "$serverUrl$url"
|
||||
}
|
||||
|
||||
if (url.contains("otakusan.net_") && !url.contains("fetcher.otakuscan.net")) {
|
||||
url += "#${isSuccess[idx]}"
|
||||
}
|
||||
|
||||
usingservers[indexServer] += 1
|
||||
}
|
||||
|
||||
Page(idx, imageUrl = url)
|
||||
}
|
||||
} else {
|
||||
val alternate = json.parseToJsonElement(
|
||||
client.newCall(
|
||||
POST(
|
||||
"$baseUrl/Manga/CheckingAlternate",
|
||||
headers,
|
||||
FormBody.Builder().add("chapId", numericId).build(),
|
||||
),
|
||||
).execute().body.string(),
|
||||
).jsonObject
|
||||
val content = alternate["Content"]?.jsonPrimitive?.content
|
||||
?: throw Exception("No pages found")
|
||||
return json.parseToJsonElement(content).jsonArray.mapIndexed { idx, it ->
|
||||
Page(idx, imageUrl = helper.processUrl(it.jsonPrimitive.content, vi))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val request = super.imageRequest(page)
|
||||
val url = request.url.toString()
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
|
||||
if (url.contains("ImageSyncing") || url.contains("FetchService") || url.contains("otakusan.net_") && (url.contains("extendContent") || url.contains("/Extend")) && !url.contains("fetcher.otakusan.net") && !url.contains("image3.otakusan.net") && !url.contains("image3.otakuscan.net") && !url.contains("[GDP]") && !url.contains("[GDT]")) {
|
||||
if (url.contains("otakusan.net_") && !url.contains("fetcher.otakuscan.net")) {
|
||||
newRequest.header("page-sign", request.url.fragment!!)
|
||||
} else {
|
||||
newRequest.header("page-lang", "vn-lang")
|
||||
}
|
||||
}
|
||||
|
||||
return newRequest.build()
|
||||
}
|
||||
|
||||
private fun getIndexLessServer(usingservers: List<Int>): Int {
|
||||
var minIndex = usingservers[0]
|
||||
var minNumber = usingservers[0]
|
||||
for (i in 1 until 3) {
|
||||
if (usingservers[i] <= minNumber) {
|
||||
minIndex = i
|
||||
minNumber = usingservers[i]
|
||||
}
|
||||
}
|
||||
return minIndex
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SERVERS = listOf("https://image2.otakuscan.net", "https://shopotaku.net", "https://image.otakuscan.net")
|
||||
val US_SERVERS = listOf("https://image3.shopotaku.net", "https://image2.otakuscan.net")
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.otakusanctuary
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
class OtakuSanctuaryHelper(private val lang: String) {
|
||||
|
||||
fun otakusanLang() = when (lang) {
|
||||
"vi" -> "vn"
|
||||
"en" -> "us"
|
||||
else -> lang
|
||||
}
|
||||
|
||||
fun processUrl(url: String, vi: String = ""): String {
|
||||
var url = url.replace("_h_", "http")
|
||||
.replace("_e_", "/extendContent/Manga")
|
||||
.replace("_r_", "/extendContent/MangaRaw")
|
||||
|
||||
if (url.startsWith("//")) {
|
||||
url = "https:$url"
|
||||
}
|
||||
if (url.contains("drive.google.com")) {
|
||||
return url
|
||||
}
|
||||
|
||||
url = when (url.slice(0..4)) {
|
||||
"[GDP]" -> url.replace("[GDP]", "https://drive.google.com/uc?export=view&id=")
|
||||
"[GDT]" -> if (otakusanLang() == "us") {
|
||||
url.replace("image2.otakuscan.net", "image3.shopotaku.net")
|
||||
.replace("image2.otakusan.net", "image3.shopotaku.net")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
"[IS1]" -> {
|
||||
val url = url.replace("[IS1]", "https://imagepi.otakuscan.net/")
|
||||
if (url.contains("vi") && url.contains("otakusan.net_")) {
|
||||
url
|
||||
} else {
|
||||
url.toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("vi", vi)
|
||||
}.build().toString()
|
||||
}
|
||||
}
|
||||
"[IS3]" -> url.replace("[IS3]", "https://image3.otakusan.net/")
|
||||
"[IO3]" -> url.replace("[IO3]", "http://image3.shopotaku.net/")
|
||||
else -> url
|
||||
}
|
||||
|
||||
if (url.contains("/Content/Workshop") || url.contains("otakusan") || url.contains("myrockmanga")) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (url.contains("file-bato-orig.anyacg.co")) {
|
||||
url = url.replace("file-bato-orig.anyacg.co", "file-bato-orig.bato.to")
|
||||
}
|
||||
|
||||
if (url.contains("file-comic")) {
|
||||
if (url.contains("file-comic-1")) {
|
||||
url = url.replace("file-comic-1.anyacg.co", "z-img-01.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-2")) {
|
||||
url = url.replace("file-comic-2.anyacg.co", "z-img-02.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-3")) {
|
||||
url = url.replace("file-comic-3.anyacg.co", "z-img-03.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-4")) {
|
||||
url = url.replace("file-comic-4.anyacg.co", "z-img-04.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-5")) {
|
||||
url = url.replace("file-comic-5.anyacg.co", "z-img-05.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-6")) {
|
||||
url = url.replace("file-comic-6.anyacg.co", "z-img-06.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-9")) {
|
||||
url = url.replace("file-comic-9.anyacg.co", "z-img-09.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-10")) {
|
||||
url = url.replace("file-comic-10.anyacg.co", "z-img-10.mangapark.net")
|
||||
}
|
||||
if (url.contains("file-comic-99")) {
|
||||
url = url.replace("file-comic-99.anyacg.co/uploads", "file-bato-0001.bato.to")
|
||||
}
|
||||
}
|
||||
|
||||
if (url.contains("cdn.nettruyen.com")) {
|
||||
url = url.replace(
|
||||
"cdn.nettruyen.com/Data/Images/",
|
||||
"truyen.cloud/data/images/",
|
||||
)
|
||||
}
|
||||
if (url.contains("url=")) {
|
||||
url = url.substringAfter("url=")
|
||||
}
|
||||
if (url.contains("blogspot") || url.contains("fshare")) {
|
||||
url = url.replace("http:", "https:")
|
||||
}
|
||||
if (url.contains("blogspot") && !url.contains("http")) {
|
||||
url = "https://$url"
|
||||
}
|
||||
if (url.contains("app/manga/uploads/") && !url.contains("http")) {
|
||||
url = "https://lhscan.net$url"
|
||||
}
|
||||
url = url.replace("//cdn.adtrue.com/rtb/async.js", "")
|
||||
|
||||
if (url.contains(".webp")) {
|
||||
url = "https://otakusan.net/api/Value/ImageSyncing?ip=34512351".toHttpUrl().newBuilder()
|
||||
.apply {
|
||||
addQueryParameter("url", url)
|
||||
}.build().toString()
|
||||
} else if (
|
||||
(
|
||||
url.contains("merakiscans") ||
|
||||
url.contains("mangazuki") ||
|
||||
url.contains("ninjascans") ||
|
||||
url.contains("anyacg.co") ||
|
||||
url.contains("mangakatana") ||
|
||||
url.contains("zeroscans") ||
|
||||
url.contains("mangapark") ||
|
||||
url.contains("mangadex") ||
|
||||
url.contains("uptruyen") ||
|
||||
url.contains("hocvientruyentranh") ||
|
||||
url.contains("ntruyen.info") ||
|
||||
url.contains("chancanvas") ||
|
||||
url.contains("bato.to")
|
||||
) &&
|
||||
(
|
||||
!url.contains("googleusercontent") &&
|
||||
!url.contains("otakusan") &&
|
||||
!url.contains("otakuscan") &&
|
||||
!url.contains("shopotaku")
|
||||
)
|
||||
) {
|
||||
url =
|
||||
"https://images2-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&gadget=a&no_expand=1&resize_h=0&rewriteMime=image%2F*".toHttpUrl()
|
||||
.newBuilder().apply {
|
||||
addQueryParameter("url", url)
|
||||
}.build().toString()
|
||||
} else if (url.contains("imageinstant.com")) {
|
||||
url = "https://images.weserv.nl/".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("url", url)
|
||||
}.build().toString()
|
||||
} else if (!url.contains("otakusan.net")) {
|
||||
url = "https://otakusan.net/api/Value/ImageSyncing?ip=34512351".toHttpUrl().newBuilder()
|
||||
.apply {
|
||||
addQueryParameter("url", url)
|
||||
}.build().toString()
|
||||
}
|
||||
|
||||
return if (url.contains("vi=") && !url.contains("otakusan.net_")) {
|
||||
url
|
||||
} else {
|
||||
url.toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("vi", vi)
|
||||
}.build().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib-multisrc/scanr/build.gradle.kts
Normal file
@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
@ -0,0 +1,182 @@
|
||||
package eu.kanade.tachiyomi.multisrc.scanr
|
||||
|
||||
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.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import kotlin.collections.iterator
|
||||
|
||||
abstract class ScanR(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
private val useHighLowQualityCover: Boolean = false,
|
||||
private val slugSeparator: String = "-",
|
||||
) : HttpSource() {
|
||||
|
||||
companion object {
|
||||
private const val SERIES_DATA_SELECTOR = "#series-data-placeholder"
|
||||
}
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
private val seriesDataCache = mutableMapOf<String, SeriesData>()
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/data/config.json", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
return searchMangaParse(response)
|
||||
}
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = if (query.isNotBlank()) {
|
||||
"$baseUrl/data/config.json#$query"
|
||||
} else {
|
||||
"$baseUrl/data/config.json"
|
||||
}
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val config = response.parseAs<ConfigResponse>()
|
||||
val mangaList = mutableListOf<SManga>()
|
||||
|
||||
val fragment = response.request.url.fragment
|
||||
val searchQuery = fragment ?: ""
|
||||
|
||||
for (fileName in config.localSeriesFiles) {
|
||||
val seriesData = fetchSeriesData(fileName)
|
||||
|
||||
if (searchQuery.isBlank() || seriesData.title.contains(
|
||||
searchQuery,
|
||||
ignoreCase = true,
|
||||
)
|
||||
) {
|
||||
mangaList.add(seriesData.toSManga(useHighLowQualityCover, slugSeparator))
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangaList, false)
|
||||
}
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
|
||||
|
||||
val seriesData = jsonData.parseAs<SeriesData>()
|
||||
return seriesData.toDetailedSManga(useHighLowQualityCover, slugSeparator)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val chapterNumber = document.location().substringAfterLast("/")
|
||||
val chapterId = extractChapterId(document, chapterNumber)
|
||||
return fetchChapterPages(chapterId)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
|
||||
|
||||
val seriesData = jsonData.parseAs<SeriesData>()
|
||||
return buildChapterList(seriesData)
|
||||
}
|
||||
|
||||
private fun fetchSeriesData(fileName: String): SeriesData {
|
||||
val cachedData = seriesDataCache[fileName]
|
||||
if (cachedData != null) {
|
||||
return cachedData
|
||||
}
|
||||
|
||||
val fileUrl = "$baseUrl/data/series/$fileName"
|
||||
val response = client.newCall(GET(fileUrl, headers)).execute()
|
||||
val seriesData = response.parseAs<SeriesData>()
|
||||
|
||||
seriesDataCache[fileName] = seriesData
|
||||
|
||||
return seriesData
|
||||
}
|
||||
|
||||
private fun extractChapterId(document: Document, chapterNumber: String): String {
|
||||
val jsonData = document.selectFirst("#reader-data-placeholder")!!.html()
|
||||
|
||||
val readerData = jsonData.parseAs<ReaderData>()
|
||||
return readerData.series.chapters
|
||||
?.get(chapterNumber)
|
||||
?.groups
|
||||
?.values
|
||||
?.firstOrNull()
|
||||
?.substringAfterLast("/")
|
||||
?: throw NoSuchElementException("Chapter data not found for chapter $chapterNumber")
|
||||
}
|
||||
|
||||
private fun buildChapterList(seriesData: SeriesData): List<SChapter> {
|
||||
val chapters = seriesData.chapters ?: return emptyList()
|
||||
val chapterList = mutableListOf<SChapter>()
|
||||
val multipleChapters = chapters.size > 1
|
||||
|
||||
for ((chapterNumber, chapterData) in chapters) {
|
||||
if (chapterData.licencied) continue
|
||||
|
||||
val title = chapterData.title ?: ""
|
||||
val volumeNumber = chapterData.volume ?: ""
|
||||
|
||||
val baseName = if (multipleChapters) {
|
||||
buildString {
|
||||
if (volumeNumber.isNotBlank()) append("Vol. $volumeNumber ")
|
||||
append("Ch. $chapterNumber")
|
||||
if (title.isNotBlank()) append(" – $title")
|
||||
}
|
||||
} else {
|
||||
if (title.isNotBlank()) "One Shot – $title" else "One Shot"
|
||||
}
|
||||
|
||||
val chapter = SChapter.create().apply {
|
||||
name = baseName
|
||||
url = "/${toSlug(seriesData.title)}/$chapterNumber"
|
||||
chapter_number = chapterNumber.toFloatOrNull() ?: -1f
|
||||
date_upload = chapterData.lastUpdated * 1000L
|
||||
}
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
|
||||
return chapterList.sortedByDescending { it.chapter_number }
|
||||
}
|
||||
|
||||
private fun fetchChapterPages(chapterId: String): List<Page> {
|
||||
val pagesResponse =
|
||||
client.newCall(GET("$baseUrl/api/imgchest-chapter-pages?id=$chapterId", headers))
|
||||
.execute()
|
||||
val pages = pagesResponse.parseAs<List<PageData>>()
|
||||
return pages.mapIndexed { index, pageData ->
|
||||
Page(index, imageUrl = pageData.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
package eu.kanade.tachiyomi.multisrc.scanr
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
object SafeLongDeserializer : KSerializer<Long> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("SafeLong", PrimitiveKind.LONG)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Long) {
|
||||
encoder.encodeLong(value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Long {
|
||||
val jsonDecoder = decoder as? JsonDecoder ?: return try {
|
||||
decoder.decodeLong()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
return try {
|
||||
val element = jsonDecoder.decodeJsonElement()
|
||||
when (element) {
|
||||
is JsonPrimitive -> {
|
||||
element.longOrNull ?: element.content.toLongOrNull() ?: 0L
|
||||
}
|
||||
|
||||
else -> 0L
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConfigResponse(
|
||||
@SerialName("LOCAL_SERIES_FILES")
|
||||
val localSeriesFiles: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriesData(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val artist: String?,
|
||||
val author: String?,
|
||||
val cover: String?,
|
||||
@SerialName("cover_low")
|
||||
val coverLow: String?,
|
||||
@SerialName("cover_hq")
|
||||
val coverHq: String?,
|
||||
val tags: List<String>?,
|
||||
@SerialName("release_status")
|
||||
val releaseStatus: String?,
|
||||
@SerialName("alternative_titles")
|
||||
val alternativeTitles: List<String>?,
|
||||
val chapters: Map<String, ChapterData>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReaderData(
|
||||
val series: SeriesData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterData(
|
||||
val title: String?,
|
||||
val volume: String?,
|
||||
@SerialName("last_updated")
|
||||
@Serializable(with = SafeLongDeserializer::class)
|
||||
val lastUpdated: Long = 0L,
|
||||
val licencied: Boolean = false,
|
||||
val groups: Map<String, String>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageData(
|
||||
val link: String,
|
||||
)
|
||||
|
||||
// DTO to SManga extension functions
|
||||
fun SeriesData.toSManga(useLowQuality: Boolean = false, slugSeparator: String): SManga =
|
||||
SManga.create().apply {
|
||||
title = this@toSManga.title
|
||||
artist = this@toSManga.artist
|
||||
author = this@toSManga.author
|
||||
thumbnail_url = if (useLowQuality) this@toSManga.coverHq else this@toSManga.cover
|
||||
url = "/${toSlug(this@toSManga.title, slugSeparator)}"
|
||||
}
|
||||
|
||||
fun SeriesData.toDetailedSManga(useHighQuality: Boolean = false, slugSeparator: String): SManga =
|
||||
SManga.create().apply {
|
||||
title = this@toDetailedSManga.title
|
||||
|
||||
val baseDescription = this@toDetailedSManga.description.let {
|
||||
if (it?.contains("Pas de synopsis", ignoreCase = true) == true) null else it
|
||||
}
|
||||
|
||||
val altTitles = this@toDetailedSManga.alternativeTitles
|
||||
description = if (!altTitles.isNullOrEmpty()) {
|
||||
buildString {
|
||||
if (!baseDescription.isNullOrBlank()) {
|
||||
append(baseDescription)
|
||||
append("\n\n")
|
||||
}
|
||||
append("Alternative Titles:\n")
|
||||
append(altTitles.joinToString("\n") { "• $it" })
|
||||
}
|
||||
} else {
|
||||
baseDescription
|
||||
}
|
||||
|
||||
artist = this@toDetailedSManga.artist
|
||||
author = this@toDetailedSManga.author
|
||||
genre = this@toDetailedSManga.tags?.joinToString(", ") ?: ""
|
||||
status = when (this@toDetailedSManga.releaseStatus) {
|
||||
"En cours" -> SManga.ONGOING
|
||||
"Finis", "Fini" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
thumbnail_url =
|
||||
if (useHighQuality) this@toDetailedSManga.coverHq else this@toDetailedSManga.cover
|
||||
url = "/${toSlug(this@toDetailedSManga.title, slugSeparator)}"
|
||||
}
|
||||
|
||||
// Utility function for slug generation
|
||||
// URLs are manually calculated using a slugify function
|
||||
fun toSlug(input: String?, slugSeparator: String = "-"): String {
|
||||
if (input == null) return ""
|
||||
|
||||
val accentsMap = mapOf(
|
||||
'à' to 'a', 'á' to 'a', 'â' to 'a', 'ä' to 'a', 'ã' to 'a',
|
||||
'è' to 'e', 'é' to 'e', 'ê' to 'e', 'ë' to 'e',
|
||||
'ì' to 'i', 'í' to 'i', 'î' to 'i', 'ï' to 'i',
|
||||
'ò' to 'o', 'ó' to 'o', 'ô' to 'o', 'ö' to 'o', 'õ' to 'o',
|
||||
'ù' to 'u', 'ú' to 'u', 'û' to 'u', 'ü' to 'u',
|
||||
'ç' to 'c', 'ñ' to 'n',
|
||||
)
|
||||
|
||||
return input
|
||||
.lowercase()
|
||||
.map { accentsMap[it] ?: it }
|
||||
.joinToString("")
|
||||
.replace("[^a-z0-9\\s-]".toRegex(), "")
|
||||
.replace("\\s".toRegex(), slugSeparator)
|
||||
}
|
||||
3
lib/clipstudioreader/build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.parser.Parser
|
||||
|
||||
abstract class ClipStudioReader : HttpSource() {
|
||||
override val client = super.client.newBuilder()
|
||||
.addInterceptor(Deobfuscator())
|
||||
.addInterceptor(ImageInterceptor())
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val requestUrl = response.request.url
|
||||
val contentId = requestUrl.queryParameter("c")
|
||||
|
||||
if (contentId != null) {
|
||||
// EPUB-based path
|
||||
val tokenUrl = "$baseUrl/api/tokens/viewer?content_id=$contentId".toHttpUrl()
|
||||
val tokenResponse = client.newCall(GET(tokenUrl, headers)).execute()
|
||||
val viewerToken = tokenResponse.parseAs<TokenResponse>().token
|
||||
|
||||
val metaUrl = "$baseUrl/api/contents/$contentId/meta".toHttpUrl()
|
||||
val apiHeaders = headersBuilder().add("Authorization", "Bearer $viewerToken").build()
|
||||
val metaResponse = client.newCall(GET(metaUrl, apiHeaders)).execute()
|
||||
val contentBaseUrl = metaResponse.parseAs<MetaResponse>().content.baseUrl
|
||||
|
||||
val preprocessUrl = "$contentBaseUrl/preprocess-settings.json"
|
||||
val obfuscationResponse = client.newCall(GET(preprocessUrl, headers)).execute()
|
||||
val obfuscationKey = obfuscationResponse.parseAs<PreprocessSettings>().obfuscateImageKey
|
||||
|
||||
val containerUrl = "$contentBaseUrl/META-INF/container.xml"
|
||||
val containerResponse = client.newCall(GET(containerUrl, headers)).execute()
|
||||
val containerDoc = Jsoup.parse(containerResponse.body.string(), containerUrl, Parser.xmlParser())
|
||||
val opfPath = containerDoc.selectFirst("*|rootfile")?.attr("full-path")
|
||||
?: throw Exception("Failed to find rootfile in container.xml")
|
||||
|
||||
val opfUrl = (contentBaseUrl.removeSuffix("/") + "/" + opfPath).toHttpUrl()
|
||||
val opfResponse = client.newCall(GET(opfUrl, headers)).execute()
|
||||
val opfDoc = opfResponse.asJsoup()
|
||||
|
||||
val imageManifestItems = opfDoc.select("*|item[media-type^=image/]")
|
||||
.sortedBy { it.attr("href") }
|
||||
if (imageManifestItems.isEmpty()) {
|
||||
throw Exception("No image pages found in the EPUB manifest")
|
||||
}
|
||||
|
||||
return imageManifestItems.mapIndexed { i, item ->
|
||||
val href = item.attr("href")
|
||||
?: throw Exception("Image item found with no href")
|
||||
val imageUrlBuilder = opfUrl.resolve(href)!!.newBuilder()
|
||||
obfuscationKey.let {
|
||||
imageUrlBuilder.addQueryParameter("obfuscateKey", it.toString())
|
||||
}
|
||||
Page(i, imageUrl = imageUrlBuilder.build().toString())
|
||||
}
|
||||
}
|
||||
|
||||
// param/cgi-based XML path
|
||||
// param/cgi in URL
|
||||
var authkey = requestUrl.queryParameter("param")?.replace(" ", "+")
|
||||
var endpoint = requestUrl.queryParameter("cgi")
|
||||
|
||||
// param/cgi in HTML
|
||||
if (authkey.isNullOrEmpty() || endpoint.isNullOrEmpty()) {
|
||||
val document = response.asJsoup()
|
||||
authkey = document.selectFirst("div#meta input[name=param]")?.attr("value")
|
||||
?: throw Exception("Could not find auth key")
|
||||
endpoint = document.selectFirst("div#meta input[name=cgi]")?.attr("value")
|
||||
?: throw Exception("Could not find endpoint")
|
||||
}
|
||||
|
||||
val viewerUrl = baseUrl.toHttpUrl().resolve(endpoint)
|
||||
?: throw Exception("Could not resolve endpoint URL: $endpoint")
|
||||
|
||||
val faceUrl = viewerUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", MODE_DL_FACE_XML)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("vm", "4")
|
||||
addQueryParameter("file", "face.xml")
|
||||
addQueryParameter("param", authkey)
|
||||
}.build()
|
||||
|
||||
val faceResponse = client.newCall(GET(faceUrl, headers)).execute()
|
||||
if (!faceResponse.isSuccessful) throw Exception("HTTP error ${faceResponse.code} while fetching face.xml")
|
||||
val faceData = faceResponse.use { parseFaceData(it.asJsoup()) }
|
||||
|
||||
return (0 until faceData.totalPages).map { i ->
|
||||
val pageFileName = i.toString().padStart(4, '0') + ".xml"
|
||||
val pageXmlUrl = viewerUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", MODE_DL_PAGE_XML)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("vm", "4")
|
||||
addQueryParameter("file", pageFileName)
|
||||
addQueryParameter("param", authkey)
|
||||
// Custom params
|
||||
addQueryParameter("csr_sw", faceData.scrambleWidth.toString())
|
||||
addQueryParameter("csr_sh", faceData.scrambleHeight.toString())
|
||||
}.build()
|
||||
Page(i, url = pageXmlUrl.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
val requestUrl = response.request.url
|
||||
val document = response.asJsoup()
|
||||
|
||||
val authkey = requestUrl.queryParameter("param")!!
|
||||
val scrambleGridW = requestUrl.queryParameter("csr_sw")!!
|
||||
val scrambleGridH = requestUrl.queryParameter("csr_sh")!!
|
||||
// Reconstruct endpoint without query params
|
||||
val endpointUrl = requestUrl.newBuilder().query(null).build()
|
||||
|
||||
val pageIndex = document.selectFirst("PageNo")?.text()?.toIntOrNull()
|
||||
?: throw Exception("Could not find PageNo")
|
||||
val scrambleArray = document.selectFirst("Scramble")?.text()
|
||||
val parts = document.select("Kind").mapNotNull {
|
||||
val type = it.text().toIntOrNull()
|
||||
val number = it.attr("No")
|
||||
val isScrambled = it.attr("scramble") == "1"
|
||||
if (type == null || number.isEmpty()) return@mapNotNull null
|
||||
|
||||
val partFileName = "${pageIndex.toString().padStart(4, '0')}_${number.padStart(4, '0')}.bin"
|
||||
PagePart(partFileName, type, isScrambled)
|
||||
}
|
||||
|
||||
val imagePart = parts.firstOrNull { it.type in SUPPORTED_IMAGE_TYPES }
|
||||
?: throw Exception("No supported image parts found for page")
|
||||
|
||||
val imageUrlBuilder = endpointUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", imagePart.type.toString())
|
||||
addQueryParameter("file", imagePart.fileName)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("param", authkey)
|
||||
}
|
||||
|
||||
if (imagePart.isScrambled && !scrambleArray.isNullOrEmpty()) {
|
||||
imageUrlBuilder.apply {
|
||||
addQueryParameter("scrambleArray", scrambleArray)
|
||||
addQueryParameter("scrambleGridW", scrambleGridW)
|
||||
addQueryParameter("scrambleGridH", scrambleGridH)
|
||||
}
|
||||
}
|
||||
return imageUrlBuilder.build().toString()
|
||||
}
|
||||
|
||||
private fun parseFaceData(document: Document): FaceData {
|
||||
val totalPages = document.selectFirst("TotalPage")?.text()?.toIntOrNull()
|
||||
val scrambleWidth = document.selectFirst("Scramble > Width")?.text()?.toIntOrNull()
|
||||
val scrambleHeight = document.selectFirst("Scramble > Height")?.text()?.toIntOrNull()
|
||||
return FaceData(totalPages!!, scrambleWidth!!, scrambleHeight!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MODE_DL_FACE_XML = "7"
|
||||
private const val MODE_DL_PAGE_XML = "8"
|
||||
private const val REQUEST_TYPE_FILE = "0"
|
||||
|
||||
private val SUPPORTED_IMAGE_TYPES = setOf(1, 2, 3) // JPEG, GIF, PNG
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class Deobfuscator : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val keyStr = request.url.queryParameter("obfuscateKey")
|
||||
|
||||
if (keyStr.isNullOrEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val key = keyStr.toInt()
|
||||
val newUrl = request.url.newBuilder().removeAllQueryParameters("obfuscateKey").build()
|
||||
val newRequest = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val obfuscatedBytes = response.body.bytes()
|
||||
val deobfuscatedBytes = deobfuscate(obfuscatedBytes, key)
|
||||
val body = deobfuscatedBytes.toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun deobfuscate(bytes: ByteArray, key: Int): ByteArray {
|
||||
val limit = minOf(bytes.size, 1024)
|
||||
for (i in 0 until limit) {
|
||||
bytes[i] = (bytes[i].toInt() xor key).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// XML
|
||||
class FaceData(
|
||||
val totalPages: Int,
|
||||
val scrambleWidth: Int,
|
||||
val scrambleHeight: Int,
|
||||
)
|
||||
|
||||
class PagePart(
|
||||
val fileName: String,
|
||||
val type: Int,
|
||||
val isScrambled: Boolean,
|
||||
)
|
||||
|
||||
// EPUB
|
||||
@Serializable
|
||||
class TokenResponse(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MetaResponse(
|
||||
val content: MetaContent,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MetaContent(
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PreprocessSettings(
|
||||
val obfuscateImageKey: Int,
|
||||
)
|
||||
@ -0,0 +1,81 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.floor
|
||||
|
||||
class ImageInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val url = request.url
|
||||
|
||||
val scrambleArray = url.queryParameter("scrambleArray")
|
||||
val scrambleGridW = url.queryParameter("scrambleGridW")?.toIntOrNull()
|
||||
val scrambleGridH = url.queryParameter("scrambleGridH")?.toIntOrNull()
|
||||
|
||||
if (scrambleArray.isNullOrEmpty() || scrambleGridW == null || scrambleGridH == null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val newUrl = url.newBuilder()
|
||||
.removeAllQueryParameters("scrambleArray")
|
||||
.removeAllQueryParameters("scrambleGridW")
|
||||
.removeAllQueryParameters("scrambleGridH")
|
||||
.build()
|
||||
val newRequest = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val scrambleMapping = scrambleArray.split(',').map { it.toInt() }
|
||||
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
|
||||
val descrambledImg = unscrambleImage(scrambledImg, scrambleMapping, scrambleGridW, scrambleGridH)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
val body = output.toByteArray().toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun unscrambleImage(
|
||||
image: Bitmap,
|
||||
scrambleMapping: List<Int>,
|
||||
gridWidth: Int,
|
||||
gridHeight: Int,
|
||||
): Bitmap {
|
||||
val descrambledImg = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(descrambledImg)
|
||||
|
||||
val pieceWidth = 8 * floor(floor(image.width.toFloat() / gridWidth) / 8).toInt()
|
||||
val pieceHeight = 8 * floor(floor(image.height.toFloat() / gridHeight) / 8).toInt()
|
||||
|
||||
if (scrambleMapping.size < gridWidth * gridHeight || image.width < 8 * gridWidth || image.height < 8 * gridHeight) {
|
||||
return image
|
||||
}
|
||||
|
||||
for (scrambleIndex in scrambleMapping.indices) {
|
||||
val destX = scrambleIndex % gridWidth * pieceWidth
|
||||
val destY = floor(scrambleIndex.toFloat() / gridWidth).toInt() * pieceHeight
|
||||
val destRect = Rect(destX, destY, destX + pieceWidth, destY + pieceHeight)
|
||||
|
||||
val sourcePieceIndex = scrambleMapping[scrambleIndex]
|
||||
val sourceX = sourcePieceIndex % gridWidth * pieceWidth
|
||||
val sourceY = floor(sourcePieceIndex.toFloat() / gridWidth).toInt() * pieceHeight
|
||||
val sourceRect = Rect(sourceX, sourceY, sourceX + pieceWidth, sourceY + pieceHeight)
|
||||
|
||||
canvas.drawBitmap(image, sourceRect, destRect, null)
|
||||
}
|
||||
return descrambledImg
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Bato.to'
|
||||
extClass = '.BatoToFactory'
|
||||
extVersionCode = 53
|
||||
extVersionCode = 56
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.extension.all.batoto
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.widget.Button
|
||||
import android.widget.Toast
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.extension.BuildConfig
|
||||
@ -54,15 +59,7 @@ open class BatoTo(
|
||||
|
||||
override val name: String = "Bato.to"
|
||||
|
||||
override var baseUrl: String = ""
|
||||
get() {
|
||||
val current = field
|
||||
if (current.isNotEmpty()) {
|
||||
return current
|
||||
}
|
||||
field = getMirrorPref()
|
||||
return field
|
||||
}
|
||||
override val baseUrl: String get() = getMirrorPref()
|
||||
|
||||
override val id: Long = when (lang) {
|
||||
"zh-Hans" -> 2818874445640189582
|
||||
@ -79,10 +76,6 @@ open class BatoTo(
|
||||
entryValues = MIRROR_PREF_ENTRY_VALUES
|
||||
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
baseUrl = newValue as String
|
||||
true
|
||||
}
|
||||
}
|
||||
val altChapterListPref = CheckBoxPreference(screen.context).apply {
|
||||
key = "${ALT_CHAPTER_LIST_PREF_KEY}_$lang"
|
||||
@ -99,9 +92,49 @@ open class BatoTo(
|
||||
"You might also want to clear the database in advanced settings."
|
||||
setDefaultValue(false)
|
||||
}
|
||||
val removeCustomPref = EditTextPreference(screen.context).apply {
|
||||
key = "${REMOVE_TITLE_CUSTOM_PREF}_$lang"
|
||||
title = "Custom regex to be removed from title"
|
||||
summary = customRemoveTitle()
|
||||
setDefaultValue("")
|
||||
|
||||
val validate = { str: String ->
|
||||
runCatching { Regex(str) }
|
||||
.map { true to "" }
|
||||
.getOrElse { false to it.message }
|
||||
}
|
||||
|
||||
setOnBindEditTextListener { editText ->
|
||||
editText.addTextChangedListener(
|
||||
object : TextWatcher {
|
||||
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
|
||||
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
|
||||
|
||||
override fun afterTextChanged(editable: Editable?) {
|
||||
editable ?: return
|
||||
val text = editable.toString()
|
||||
val valid = validate(text)
|
||||
editText.error = if (!valid.first) valid.second else null
|
||||
editText.rootView.findViewById<Button>(android.R.id.button1)?.isEnabled = editText.error == null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val (isValid, message) = validate(newValue as String)
|
||||
if (isValid) {
|
||||
summary = newValue
|
||||
} else {
|
||||
Toast.makeText(screen.context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
isValid
|
||||
}
|
||||
}
|
||||
screen.addPreference(mirrorPref)
|
||||
screen.addPreference(altChapterListPref)
|
||||
screen.addPreference(removeOfficialPref)
|
||||
screen.addPreference(removeCustomPref)
|
||||
}
|
||||
|
||||
private fun getMirrorPref(): String {
|
||||
@ -132,12 +165,14 @@ open class BatoTo(
|
||||
private fun isRemoveTitleVersion(): Boolean {
|
||||
return preferences.getBoolean("${REMOVE_TITLE_VERSION_PREF}_$lang", false)
|
||||
}
|
||||
private fun customRemoveTitle(): String =
|
||||
preferences.getString("${REMOVE_TITLE_CUSTOM_PREF}_$lang", "")!!
|
||||
|
||||
private fun SharedPreferences.migrateMirrorPref() {
|
||||
val selectedMirror = getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)!!
|
||||
|
||||
if (selectedMirror in DEPRECATED_MIRRORS) {
|
||||
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).commit()
|
||||
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,8 +196,9 @@ open class BatoTo(
|
||||
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.setUrlWithoutDomain(stripSeriesUrl(item.attr("href")))
|
||||
manga.title = element.select("a.item-title").text().removeEntities()
|
||||
.cleanTitleIfNeeded()
|
||||
manga.thumbnail_url = imgurl
|
||||
return manga
|
||||
}
|
||||
@ -287,9 +323,10 @@ open class BatoTo(
|
||||
val infoElement = document.select("div#mainer div.container-fluid")
|
||||
val manga = SManga.create()
|
||||
manga.title = infoElement.select("h3").text().removeEntities()
|
||||
.cleanTitleIfNeeded()
|
||||
manga.thumbnail_url = document.select("div.attr-cover img")
|
||||
.attr("abs:src")
|
||||
manga.setUrlWithoutDomain(infoElement.select("h3 a").attr("abs:href"))
|
||||
manga.setUrlWithoutDomain(stripSeriesUrl(infoElement.select("h3 a").attr("abs:href")))
|
||||
return MangasPage(listOf(manga), false)
|
||||
}
|
||||
|
||||
@ -320,16 +357,18 @@ open class BatoTo(
|
||||
|
||||
private fun searchUtilsFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
|
||||
manga.setUrlWithoutDomain(stripSeriesUrl(element.select("td a").attr("href")))
|
||||
manga.title = element.select("td a").text()
|
||||
.cleanTitleIfNeeded()
|
||||
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.setUrlWithoutDomain(stripSeriesUrl(element.select(".position-relative a").attr("href")))
|
||||
manga.title = element.select(".position-relative a").text()
|
||||
.cleanTitleIfNeeded()
|
||||
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
@ -358,8 +397,6 @@ open class BatoTo(
|
||||
}
|
||||
return super.mangaDetailsRequest(manga)
|
||||
}
|
||||
private var titleRegex: Regex =
|
||||
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|\\/Official|\\/ Official", RegexOption.IGNORE_CASE)
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.selectFirst("div#mainer div.container-fluid")!!
|
||||
@ -380,19 +417,19 @@ open class BatoTo(
|
||||
append(it.text().split('/').joinToString("\n") { "• ${it.trim()}" })
|
||||
}
|
||||
}.trim()
|
||||
|
||||
val cleanedTitle = if (isRemoveTitleVersion()) {
|
||||
originalTitle.replace(titleRegex, "").trim()
|
||||
} else {
|
||||
originalTitle
|
||||
}
|
||||
val cleanedTitle = originalTitle.cleanTitleIfNeeded()
|
||||
|
||||
manga.title = cleanedTitle
|
||||
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 = description
|
||||
manga.description = if (originalTitle.trim() != cleanedTitle) {
|
||||
listOf(originalTitle, description)
|
||||
.joinToString("\n\n")
|
||||
} else {
|
||||
description
|
||||
}
|
||||
manga.thumbnail_url = document.select("div.attr-cover img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
@ -436,9 +473,9 @@ open class BatoTo(
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return if (getAltChapterListPref()) {
|
||||
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
|
||||
|
||||
val id = seriesIdRegex.find(manga.url)
|
||||
?.groups?.get(1)?.value?.trim()
|
||||
return if (getAltChapterListPref() && !id.isNullOrBlank()) {
|
||||
GET("$baseUrl/rss/series/$id.xml", headers)
|
||||
} else if (manga.url.startsWith("http")) {
|
||||
// Check if trying to use a deprecated mirror, force current mirror
|
||||
@ -583,6 +620,19 @@ open class BatoTo(
|
||||
|
||||
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
|
||||
|
||||
private fun String.cleanTitleIfNeeded(): String {
|
||||
var tempTitle = this
|
||||
customRemoveTitle().takeIf { it.isNotEmpty() }?.let { customRegex ->
|
||||
runCatching {
|
||||
tempTitle = tempTitle.replace(Regex(customRegex), "")
|
||||
}
|
||||
}
|
||||
if (isRemoveTitleVersion()) {
|
||||
tempTitle = tempTitle.replace(titleRegex, "")
|
||||
}
|
||||
return tempTitle.trim()
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
LetterFilter(getLetterFilter(), 0),
|
||||
Filter.Separator(),
|
||||
@ -1038,50 +1088,94 @@ open class BatoTo(
|
||||
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
|
||||
).filterNot { it.value == siteLang }
|
||||
|
||||
private fun stripSeriesUrl(url: String): String {
|
||||
val matchResult = seriesUrlRegex.find(url)
|
||||
return matchResult?.groups?.get(1)?.value ?: url
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val seriesUrlRegex = Regex("""(.*/series/\d+)/.*""")
|
||||
private val seriesIdRegex = Regex("""series/(\d+)""")
|
||||
private const val MIRROR_PREF_KEY = "MIRROR"
|
||||
private const val MIRROR_PREF_TITLE = "Mirror"
|
||||
private const val REMOVE_TITLE_VERSION_PREF = "REMOVE_TITLE_VERSION"
|
||||
private const val REMOVE_TITLE_CUSTOM_PREF = "REMOVE_TITLE_CUSTOM"
|
||||
private val MIRROR_PREF_ENTRIES = arrayOf(
|
||||
"Auto",
|
||||
"batocomic.com",
|
||||
"batocomic.net",
|
||||
"batocomic.org",
|
||||
// https://batotomirrors.pages.dev/
|
||||
"ato.to",
|
||||
"dto.to",
|
||||
"fto.to",
|
||||
"hto.to",
|
||||
"jto.to",
|
||||
"lto.to",
|
||||
"mto.to",
|
||||
"nto.to",
|
||||
"vto.to",
|
||||
"wto.to",
|
||||
"xto.to",
|
||||
"yto.to",
|
||||
"vba.to",
|
||||
"wba.to",
|
||||
"xba.to",
|
||||
"yba.to",
|
||||
"zba.to",
|
||||
"bato.ac",
|
||||
"bato.bz",
|
||||
"bato.cc",
|
||||
"bato.cx",
|
||||
"bato.id",
|
||||
"bato.pw",
|
||||
"bato.sh",
|
||||
"bato.to",
|
||||
"bato.vc",
|
||||
"bato.day",
|
||||
"bato.red",
|
||||
"bato.run",
|
||||
"batoto.in",
|
||||
"batoto.tv",
|
||||
"batotoo.com",
|
||||
"batotwo.com",
|
||||
"batpub.com",
|
||||
"batread.com",
|
||||
"battwo.com",
|
||||
"comiko.net",
|
||||
"comiko.org",
|
||||
"readtoto.com",
|
||||
"readtoto.net",
|
||||
"readtoto.org",
|
||||
"xbato.com",
|
||||
"xbato.net",
|
||||
"xbato.org",
|
||||
"zbato.com",
|
||||
"zbato.net",
|
||||
"zbato.org",
|
||||
"dto.to",
|
||||
"fto.to",
|
||||
"hto.to",
|
||||
"jto.to",
|
||||
"mto.to",
|
||||
"wto.to",
|
||||
"comiko.net",
|
||||
"comiko.org",
|
||||
"mangatoto.com",
|
||||
"mangatoto.net",
|
||||
"mangatoto.org",
|
||||
"batocomic.com",
|
||||
"batocomic.net",
|
||||
"batocomic.org",
|
||||
"readtoto.com",
|
||||
"readtoto.net",
|
||||
"readtoto.org",
|
||||
"kuku.to",
|
||||
"okok.to",
|
||||
"ruru.to",
|
||||
"xdxd.to",
|
||||
// "bato.si", // (v4)
|
||||
// "bato.ing", // (v4)
|
||||
)
|
||||
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 val DEPRECATED_MIRRORS = listOf(
|
||||
"https://bato.to",
|
||||
"https://batocc.com", // parked
|
||||
"https://mangatoto.com",
|
||||
"https://mangatoto.net",
|
||||
"https://mangatoto.org",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
private val titleRegex: Regex =
|
||||
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|\uD81A\uDD0D.+?\uD81A\uDD0D|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|/Official|/ Official", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
ext {
|
||||
extName = 'Comic Growl'
|
||||
extClass = '.ComicGrowl'
|
||||
themePkg = 'comiciviewer'
|
||||
baseUrl = 'https://comic-growl.com'
|
||||
extVersionCode = 7
|
||||
overrideVersionCode = 7
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
||||
@ -1,212 +1,9 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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 keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import eu.kanade.tachiyomi.multisrc.comiciviewer.ComiciViewer
|
||||
|
||||
class ComicGrowl(
|
||||
override val lang: String = "all",
|
||||
override val baseUrl: String = "https://comic-growl.com",
|
||||
override val name: String = "コミックグロウル",
|
||||
override val supportsLatest: Boolean = true,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val client = super.client.newBuilder()
|
||||
.addNetworkInterceptor(ImageDescrambler::interceptor)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder {
|
||||
return super.headersBuilder().set("Referer", "$baseUrl/")
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking/manga", headers)
|
||||
|
||||
override fun popularMangaNextPageSelector() = null
|
||||
|
||||
override fun popularMangaSelector() = ".ranking-item"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
title = element.selectFirst(".title-text")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.selectFirst(".series-h-info")!!
|
||||
val authorElements = infoElement.select(".series-h-credit-user-item .article-text")
|
||||
val updateDateElement = infoElement.selectFirst(".series-h-tag-label")
|
||||
return SManga.create().apply {
|
||||
title = infoElement.selectFirst("h1 > span:not(.g-hidden)")!!.text()
|
||||
author = authorElements.joinToString { it.text() }
|
||||
description = infoElement.selectFirst(".series-h-credit-info-text-text p")?.wholeText()?.trim()
|
||||
setImageUrlFromElement(document.selectFirst(".series-h-img"))
|
||||
status = if (updateDateElement != null) SManga.ONGOING else SManga.COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url + "/list", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return document.select(chapterListSelector()).mapIndexed { index, element ->
|
||||
chapterFromElement(element).apply {
|
||||
chapter_number = index.toFloat()
|
||||
if (url.isEmpty()) { // need login, set a dummy url and append lock icon for chapter name
|
||||
val hasLockElement = element.selectFirst(".g-payment-article.wait-free-enabled")
|
||||
url = response.request.url.newBuilder().fragment("$index-$DUMMY_URL_SUFFIX").build().toString()
|
||||
name = (if (hasLockElement != null) LOCK_ICON else PAY_ICON) + name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".article-ep-list-item-img-link"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
return SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("data-href"))
|
||||
name = element.selectFirst(".series-ep-list-item-h-text")!!.text()
|
||||
setUploadDate(element.selectFirst(".series-ep-list-date-time"))
|
||||
scanlator = PUBLISHER
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) {
|
||||
throw Exception("Login required to see this chapter")
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val pageList = mutableListOf<Page>()
|
||||
|
||||
// Get some essential info from document
|
||||
val viewer = document.selectFirst("#comici-viewer")!!
|
||||
val comiciViewerId = viewer.attr("comici-viewer-id")
|
||||
val memberJwt = viewer.attr("data-member-jwt")
|
||||
val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("comici-viewer-id", comiciViewerId)
|
||||
.addQueryParameter("user-id", memberJwt)
|
||||
.addQueryParameter("page-from", "0")
|
||||
|
||||
// Initial request to get total pages
|
||||
val initialRequest = GET(requestUrl.addQueryParameter("page-to", "1").build(), headers)
|
||||
client.newCall(initialRequest).execute().use { initialResponseRaw ->
|
||||
if (!initialResponseRaw.isSuccessful) {
|
||||
throw Exception("Failed to get page list")
|
||||
}
|
||||
|
||||
// Get all pages
|
||||
val pageTo = initialResponseRaw.parseAs<PageResponse>().totalPages.toString()
|
||||
val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build()
|
||||
val getAllPagesRequest = GET(getAllPagesUrl, headers)
|
||||
client.newCall(getAllPagesRequest).execute().use {
|
||||
if (!it.isSuccessful) {
|
||||
throw Exception("Failed to get page list")
|
||||
}
|
||||
|
||||
it.parseAs<PageResponse>().result.forEach { resultItem ->
|
||||
// Origin scramble string is something like [6, 9, 14, 15, 8, 3, 4, 12, 1, 5, 0, 7, 13, 2, 11, 10]
|
||||
val scramble = resultItem.scramble.drop(1).dropLast(1).replace(", ", "-")
|
||||
// Add fragment to let interceptor descramble the image
|
||||
val imageUrl = resultItem.imageUrl.toHttpUrl().newBuilder().fragment(scramble).build()
|
||||
|
||||
pageList.add(
|
||||
Page(index = resultItem.sort, imageUrl = imageUrl.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageList
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val searchUrl = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.setQueryParameter("keyword", query)
|
||||
.setQueryParameter("page", page.toString())
|
||||
.build()
|
||||
return GET(searchUrl, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun searchMangaSelector() = ".series-list a"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
title = element.selectFirst(".manga-title")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = null
|
||||
|
||||
override fun latestUpdatesSelector() = "h2:contains(新連載) + .feature-list > .feature-item"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
title = element.selectFirst("h3")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
|
||||
// ========================================= Helper Functions =====================================
|
||||
|
||||
companion object {
|
||||
private const val PUBLISHER = "BUSHIROAD WORKS"
|
||||
|
||||
private val imageUrlRegex by lazy { Regex("^.*?webp") }
|
||||
|
||||
private val DATE_PARSER by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) }
|
||||
|
||||
private const val DUMMY_URL_SUFFIX = "NeedLogin"
|
||||
|
||||
private const val PAY_ICON = "💴 "
|
||||
private const val LOCK_ICON = "🔒 "
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover image url from [element] for [SManga]
|
||||
*/
|
||||
private fun SManga.setImageUrlFromElement(element: Element?) {
|
||||
if (element == null) {
|
||||
return
|
||||
}
|
||||
val match = imageUrlRegex.find(element.selectFirst("source")!!.attr("data-srcset"))
|
||||
// Add missing protocol
|
||||
if (match != null) {
|
||||
this.thumbnail_url = "https:${match.value}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set date_upload to [SChapter], parsing from string like "3月31日" to UNIX Epoch time.
|
||||
*/
|
||||
private fun SChapter.setUploadDate(element: Element?) {
|
||||
if (element == null) {
|
||||
return
|
||||
}
|
||||
this.date_upload = DATE_PARSER.tryParse(element.attr("datetime"))
|
||||
}
|
||||
}
|
||||
class ComicGrowl : ComiciViewer(
|
||||
"Comic Growl",
|
||||
"https://comic-growl.com",
|
||||
"all",
|
||||
)
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class PageResponse(
|
||||
val totalPages: Int,
|
||||
val result: List<PageResponseResult>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PageResponseResult(
|
||||
val imageUrl: String,
|
||||
val scramble: String,
|
||||
val sort: Int,
|
||||
)
|
||||
12
src/all/comicklive/build.gradle
Normal file
@ -0,0 +1,12 @@
|
||||
ext {
|
||||
extName = 'Comick (Unoriginal)'
|
||||
extClass = '.ComickFactory'
|
||||
extVersionCode = 3
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
|
||||
}
|
||||
BIN
src/all/comicklive/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src/all/comicklive/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/all/comicklive/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/all/comicklive/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/all/comicklive/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@ -0,0 +1,441 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicklive
|
||||
|
||||
import android.util.Log
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
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 keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.firstInstanceOrNull
|
||||
import keiyoushi.utils.getPreferences
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class Comick(
|
||||
override val lang: String,
|
||||
private val siteLang: String = lang,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val name = "Comick (Unoriginal)"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val preferences = getPreferences()
|
||||
|
||||
override val baseUrl: String
|
||||
get() {
|
||||
val index = preferences.getString(DOMAIN_PREF, "0")!!.toInt()
|
||||
.coerceAtMost(domains.size - 1)
|
||||
|
||||
return domains[index]
|
||||
}
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
// Referer in interceptor due to domain change preference
|
||||
.addNetworkInterceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Referer", "$baseUrl/")
|
||||
.build()
|
||||
|
||||
chain.proceed(request)
|
||||
}
|
||||
// fix disk cache
|
||||
.apply {
|
||||
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
|
||||
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$baseUrl/api/comics/top".toHttpUrl().newBuilder().apply {
|
||||
val days = when (page) {
|
||||
1, 4 -> 7
|
||||
2, 5 -> 30
|
||||
3, 6 -> 90
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
val type = when (page) {
|
||||
1, 2, 3 -> "follow"
|
||||
4, 5, 6 -> "most_follow_new"
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
addQueryParameter("days", days.toString())
|
||||
addQueryParameter("type", type)
|
||||
fragment(page.toString())
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<Data<List<BrowseComic>>>()
|
||||
val page = response.request.url.fragment!!.toInt()
|
||||
|
||||
return MangasPage(
|
||||
mangas = data.data.map(BrowseComic::toSManga),
|
||||
hasNextPage = page < 6,
|
||||
)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
GET("$baseUrl/api/chapters/latest?order=new&page=$page", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<Data<List<BrowseComic>>>()
|
||||
|
||||
return MangasPage(
|
||||
mangas = data.data.map(BrowseComic::toSManga),
|
||||
hasNextPage = data.data.size == 100,
|
||||
)
|
||||
}
|
||||
|
||||
private var nextCursor: String? = null
|
||||
|
||||
private val spaceSlashRegex = Regex("[ /]")
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (page == 1) {
|
||||
nextCursor = null
|
||||
}
|
||||
|
||||
val url = "$baseUrl/api/search".toHttpUrl().newBuilder().apply {
|
||||
filters.firstInstance<SortFilter>().let {
|
||||
addQueryParameter("order_by", it.selected)
|
||||
addQueryParameter("order_direction", if (it.state!!.ascending) "asc" else "desc")
|
||||
}
|
||||
filters.firstInstanceOrNull<GenreFilter>()?.let { genre ->
|
||||
genre.included.forEach {
|
||||
addQueryParameter("genres", it)
|
||||
}
|
||||
genre.excluded.forEach {
|
||||
addQueryParameter("excludes", it)
|
||||
}
|
||||
}
|
||||
filters.firstInstanceOrNull<TagFilterText>()?.let { text ->
|
||||
text.state.split(",").filter(String::isNotBlank).forEach {
|
||||
val value = it.trim().lowercase().replace(spaceSlashRegex, "-")
|
||||
addQueryParameter(
|
||||
if (value.startsWith("-")) "excluded_tags" else "tags",
|
||||
value.replaceFirst("-", ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
filters.firstInstanceOrNull<TagFilter>()?.let { tag ->
|
||||
tag.included.forEach {
|
||||
addQueryParameter("tags", it)
|
||||
}
|
||||
tag.excluded.forEach {
|
||||
addQueryParameter("excluded_tags", it)
|
||||
}
|
||||
}
|
||||
filters.firstInstance<DemographicFilter>().checked.forEach {
|
||||
addQueryParameter("demographic", it)
|
||||
}
|
||||
filters.firstInstance<CreatedAtFilter>().selected?.let {
|
||||
addQueryParameter("time", it)
|
||||
}
|
||||
filters.firstInstance<TypeFilter>().checked.forEach {
|
||||
addQueryParameter("country", it)
|
||||
}
|
||||
filters.firstInstance<MinimumChaptersFilter>().state.let {
|
||||
if (it.isNotBlank()) {
|
||||
if (it.toIntOrNull() == null) {
|
||||
throw Exception("Invalid minimum chapters value: $it")
|
||||
}
|
||||
addQueryParameter("minimum", it)
|
||||
}
|
||||
}
|
||||
filters.firstInstance<StatusFilter>().selected?.let {
|
||||
addQueryParameter("status", it)
|
||||
}
|
||||
filters.firstInstance<ReleaseFrom>().selected?.let {
|
||||
addQueryParameter("from", it)
|
||||
}
|
||||
filters.firstInstance<ReleaseTo>().selected?.let {
|
||||
addQueryParameter("to", it)
|
||||
}
|
||||
filters.firstInstance<ContentRatingFilter>().selected?.let {
|
||||
addQueryParameter("content_rating", it)
|
||||
}
|
||||
addQueryParameter("showAll", "false")
|
||||
addQueryParameter("exclude_mylist", "false")
|
||||
if (query.isNotBlank()) {
|
||||
if (query.trim().length < 3) {
|
||||
throw Exception("Query must be at least 3 characters")
|
||||
}
|
||||
addQueryParameter("q", query.trim())
|
||||
}
|
||||
addQueryParameter("type", "comic")
|
||||
if (page > 1) {
|
||||
addQueryParameter("cursor", nextCursor)
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<SearchResponse>()
|
||||
|
||||
nextCursor = data.cursor
|
||||
|
||||
return MangasPage(
|
||||
mangas = data.data.map(BrowseComic::toSManga),
|
||||
hasNextPage = data.cursor != null,
|
||||
)
|
||||
}
|
||||
|
||||
private val metadataClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
chain.proceed(chain.request()).newBuilder()
|
||||
.header("Cache-Control", "max-age=${24 * 60 * 60}")
|
||||
.removeHeader("Pragma")
|
||||
.removeHeader("Expires")
|
||||
.build()
|
||||
}.build()
|
||||
|
||||
override fun getFilterList(): FilterList = runBlocking(Dispatchers.IO) {
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
SortFilter(),
|
||||
DemographicFilter(),
|
||||
TypeFilter(),
|
||||
CreatedAtFilter(),
|
||||
MinimumChaptersFilter(),
|
||||
StatusFilter(),
|
||||
ContentRatingFilter(),
|
||||
ReleaseFrom(),
|
||||
ReleaseTo(),
|
||||
)
|
||||
|
||||
val response = metadataClient.newCall(
|
||||
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_CACHE),
|
||||
).await()
|
||||
|
||||
val getTags = preferences.getBoolean(GET_TAGS, true)
|
||||
|
||||
val textTags: List<Filter<*>> = listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
TagFilterText(),
|
||||
Filter.Separator(),
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
metadataClient.newCall(
|
||||
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_NETWORK),
|
||||
).enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.closeQuietly()
|
||||
}
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(name, "Unable to fetch filters", e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (!getTags) {
|
||||
filters.addAll(
|
||||
index = 2,
|
||||
textTags,
|
||||
)
|
||||
}
|
||||
filters.addAll(
|
||||
index = 0,
|
||||
listOf(
|
||||
Filter.Header("Press 'reset' to load genres ${if (getTags) "and tags" else ""}"),
|
||||
Filter.Separator(),
|
||||
),
|
||||
)
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
val data = try {
|
||||
response.parseAs<Metadata>()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(name, "Unable to parse filters", e)
|
||||
|
||||
if (!getTags) {
|
||||
filters.addAll(
|
||||
index = 2,
|
||||
textTags,
|
||||
)
|
||||
}
|
||||
filters.addAll(
|
||||
index = 0,
|
||||
listOf(
|
||||
Filter.Header("Failed to parse genres ${if (getTags) "and tags" else ""}"),
|
||||
Filter.Separator(),
|
||||
),
|
||||
)
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
filters.add(
|
||||
index = 3,
|
||||
GenreFilter(data.genres),
|
||||
)
|
||||
if (!getTags) {
|
||||
filters.addAll(
|
||||
index = 4,
|
||||
textTags,
|
||||
)
|
||||
} else {
|
||||
filters.add(
|
||||
index = 4,
|
||||
TagFilter(data.tags),
|
||||
)
|
||||
}
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
GET("$baseUrl/comic/${manga.url}", headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val data = response.asJsoup()
|
||||
.selectFirst("#comic-data")!!.data()
|
||||
.parseAs<ComicData>()
|
||||
|
||||
return SManga.create().apply {
|
||||
title = data.title
|
||||
url = data.slug
|
||||
thumbnail_url = data.thumbnail
|
||||
status = when (data.status) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> if (data.translationCompleted) SManga.COMPLETED else SManga.PUBLISHING_FINISHED
|
||||
3 -> SManga.CANCELLED
|
||||
4 -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
author = data.authors.joinToString { it.name }
|
||||
artist = data.artists.joinToString { it.name }
|
||||
description = buildString {
|
||||
append(
|
||||
Jsoup.parseBodyFragment(data.desc).wholeText(),
|
||||
)
|
||||
|
||||
if (data.titles.isNotEmpty()) {
|
||||
append("\n\n Alternative Titles: \n")
|
||||
data.titles.forEach {
|
||||
append("- ", it.title.trim(), "\n")
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
genre = buildList {
|
||||
when (data.country) {
|
||||
"jp" -> add("Manga")
|
||||
"cn" -> add("Manhua")
|
||||
"ko" -> add("Manhwa")
|
||||
}
|
||||
when (data.contentRating) {
|
||||
"suggestive" -> add("Content Rating: Suggestive")
|
||||
"erotica" -> add("Content Rating: Erotica")
|
||||
}
|
||||
addAll(data.genres.map { it.genres.name })
|
||||
}.joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
GET("$baseUrl/api/comics/${manga.url}/chapter-list?lang=$siteLang", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
var data = response.parseAs<ChapterList>()
|
||||
var page = 2
|
||||
val chapters = data.data.toMutableList()
|
||||
|
||||
while (data.hasNextPage()) {
|
||||
val url = response.request.url.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
.build()
|
||||
|
||||
data = client.newCall(GET(url, headers)).execute()
|
||||
.parseAs()
|
||||
chapters += data.data
|
||||
page++
|
||||
}
|
||||
|
||||
val mangaSlug = response.request.url.pathSegments[2]
|
||||
|
||||
return chapters.map {
|
||||
SChapter.create().apply {
|
||||
url = "/comic/$mangaSlug/${it.hid}-chapter-${it.chap}-${it.lang}"
|
||||
name = buildString {
|
||||
if (!it.vol.isNullOrBlank()) {
|
||||
append("Vol. ", it.vol, " ")
|
||||
}
|
||||
append("Ch. ", it.chap)
|
||||
if (!it.title.isNullOrBlank()) {
|
||||
append(": ", it.title)
|
||||
}
|
||||
}
|
||||
date_upload = dateFormat.tryParse(it.createdAt)
|
||||
scanlator = it.groups.joinToString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val data = response.asJsoup()
|
||||
.selectFirst("#sv-data")!!.data()
|
||||
.parseAs<PageListData>()
|
||||
|
||||
return data.chapter.images.mapIndexed { index, image ->
|
||||
Page(index, imageUrl = image.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = DOMAIN_PREF
|
||||
title = "Preferred Domain"
|
||||
entries = domains
|
||||
entryValues = Array(domains.size) { it.toString() }
|
||||
summary = "%s"
|
||||
setDefaultValue("0")
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = GET_TAGS
|
||||
title = "Tags Input Type"
|
||||
summaryOn = "Tags will be in a form of scrollable list"
|
||||
summaryOff = "Tags will need to be inputted manually"
|
||||
setDefaultValue(true)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
}
|
||||
|
||||
private val domains = arrayOf("https://comick.live", "https://comick.art")
|
||||
private const val DOMAIN_PREF = "domain_pref"
|
||||
private const val GET_TAGS = "get_tags"
|
||||
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicklive
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class ComickFactory : SourceFactory {
|
||||
// as of 2025-10-15, the commented languages have 0 chapters uploaded
|
||||
// from: /api/languages
|
||||
override fun createSources() = listOf(
|
||||
Comick("en"),
|
||||
// Comick("pt-br", "pt-BR"),
|
||||
// Comick("es-419", "es-la"),
|
||||
Comick("ru"),
|
||||
Comick("vi"),
|
||||
Comick("fr"),
|
||||
Comick("pl"),
|
||||
Comick("id"),
|
||||
Comick("tr"),
|
||||
Comick("it"),
|
||||
Comick("es"),
|
||||
Comick("uk"),
|
||||
// Comick("ar"),
|
||||
// Comick("zh-hk", "zh-Hant"),
|
||||
// Comick("hu"),
|
||||
// Comick("zh", "zh-Hans"),
|
||||
Comick("de"),
|
||||
Comick("ko"),
|
||||
Comick("th"),
|
||||
// Comick("ca"),
|
||||
// Comick("bg"),
|
||||
// Comick("fa"),
|
||||
Comick("ro"),
|
||||
// Comick("cs"),
|
||||
// Comick("mn"),
|
||||
// Comick("pt"),
|
||||
// Comick("he"),
|
||||
// Comick("hi"),
|
||||
// Comick("tl"),
|
||||
Comick("ms"),
|
||||
// Comick("fi"),
|
||||
// Comick("eu"),
|
||||
// Comick("kk"),
|
||||
// Comick("sr"),
|
||||
// Comick("my"),
|
||||
Comick("ja"),
|
||||
// Comick("el"),
|
||||
// Comick("nl"),
|
||||
// Comick("bn"),
|
||||
// Comick("uz"),
|
||||
// Comick("eo"),
|
||||
// Comick("ka"),
|
||||
// Comick("lt"),
|
||||
// Comick("da"),
|
||||
// Comick("ta"),
|
||||
Comick("sv"),
|
||||
// Comick("be"),
|
||||
// Comick("gl"),
|
||||
// Comick("cv"),
|
||||
// Comick("hr"),
|
||||
// Comick("la"),
|
||||
// Comick("ur"),
|
||||
// Comick("ne"),
|
||||
Comick("no"),
|
||||
// Comick("sq"),
|
||||
// Comick("ga"),
|
||||
// Comick("jv"),
|
||||
// Comick("te"),
|
||||
// Comick("sl"),
|
||||
// Comick("et"),
|
||||
// Comick("az"),
|
||||
// Comick("sk"),
|
||||
// Comick("af"),
|
||||
// Comick("lv"),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicklive
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
|
||||
@Serializable
|
||||
class Data<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class SearchResponse(
|
||||
val data: List<BrowseComic>,
|
||||
@SerialName("next_cursor")
|
||||
val cursor: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class BrowseComic(
|
||||
@SerialName("default_thumbnail")
|
||||
private val thumbnail: String,
|
||||
private val slug: String,
|
||||
private val title: String,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = slug
|
||||
title = this@BrowseComic.title
|
||||
thumbnail_url = thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Metadata(
|
||||
val genres: List<Name>,
|
||||
val tags: List<Name>,
|
||||
) {
|
||||
@Serializable
|
||||
class Name(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ComicData(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
@SerialName("default_thumbnail")
|
||||
val thumbnail: String,
|
||||
val status: Int,
|
||||
@SerialName("translation_completed")
|
||||
val translationCompleted: Boolean,
|
||||
val artists: List<Name>,
|
||||
val authors: List<Name>,
|
||||
val desc: String,
|
||||
@SerialName("content_rating")
|
||||
val contentRating: String,
|
||||
val country: String,
|
||||
@SerialName("md_comic_md_genres")
|
||||
val genres: List<Genres>,
|
||||
@SerialName("md_titles")
|
||||
@Serializable(with = TitleTransform::class)
|
||||
val titles: List<Title>,
|
||||
) {
|
||||
@Serializable
|
||||
class Name(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Title(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Genres(
|
||||
@SerialName("md_genres")
|
||||
val genres: Name,
|
||||
)
|
||||
}
|
||||
|
||||
object TitleTransform : JsonTransformingSerializer<List<ComicData.Title>>(
|
||||
ListSerializer(ComicData.Title.serializer()),
|
||||
) {
|
||||
override fun transformDeserialize(element: JsonElement): JsonElement {
|
||||
if (element !is JsonObject) return element
|
||||
return JsonArray(element.values.toList())
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterList(
|
||||
val data: List<Chapter>,
|
||||
private val pagination: Pagination,
|
||||
) {
|
||||
fun hasNextPage() = pagination.page < pagination.lastPage
|
||||
|
||||
@Serializable
|
||||
class Chapter(
|
||||
val hid: String,
|
||||
val chap: String,
|
||||
val vol: String?,
|
||||
val lang: String,
|
||||
val title: String?,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("group_name")
|
||||
val groups: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Pagination(
|
||||
@SerialName("current_page")
|
||||
val page: Int,
|
||||
@SerialName("last_page")
|
||||
val lastPage: Int,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class PageListData(
|
||||
val chapter: ChapterData,
|
||||
) {
|
||||
@Serializable
|
||||
class ChapterData(
|
||||
val images: List<Image>,
|
||||
) {
|
||||
@Serializable
|
||||
class Image(
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicklive
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import java.util.Calendar
|
||||
import kotlin.collections.filter
|
||||
|
||||
abstract class SelectFilter(
|
||||
name: String,
|
||||
private val options: List<Pair<String, String>>,
|
||||
) : Filter.Select<String>(
|
||||
name,
|
||||
options.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
val selected get() = options[state].second.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name)
|
||||
|
||||
abstract class CheckBoxGroup(
|
||||
name: String,
|
||||
options: List<Pair<String, String>>,
|
||||
) : Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
options.map { CheckBoxFilter(it.first, it.second) },
|
||||
) {
|
||||
val checked get() = state.filter { it.state }.map { it.value }
|
||||
}
|
||||
|
||||
class TriStateFilter(name: String, val slug: String) : Filter.TriState(name)
|
||||
|
||||
abstract class TriStateGroupFilter(
|
||||
name: String,
|
||||
options: List<Pair<String, String>>,
|
||||
) : Filter.Group<TriStateFilter>(
|
||||
name,
|
||||
options.map { TriStateFilter(it.first, it.second) },
|
||||
) {
|
||||
val included get() = state.filter { it.isIncluded() }.map { it.slug }
|
||||
val excluded get() = state.filter { it.isExcluded() }.map { it.slug }
|
||||
}
|
||||
|
||||
private val getSortsList = listOf(
|
||||
"Latest" to "created_at",
|
||||
"Popular" to "user_follow_count",
|
||||
"Highest Rating" to "rating",
|
||||
"Last Uploaded" to "uploaded",
|
||||
)
|
||||
|
||||
class SortFilter : Filter.Sort(
|
||||
name = "Sort",
|
||||
values = getSortsList.map { it.first }.toTypedArray(),
|
||||
state = Selection(0, false),
|
||||
) {
|
||||
val selected get() = state?.let { getSortsList[it.index] }?.second.takeIf { it?.isNotEmpty() ?: false }
|
||||
}
|
||||
|
||||
class GenreFilter(genres: List<Metadata.Name>) : TriStateGroupFilter(
|
||||
name = "Genre",
|
||||
options = genres.map { it.name to it.slug },
|
||||
)
|
||||
|
||||
class TagFilter(tags: List<Metadata.Name>) : TriStateGroupFilter(
|
||||
name = "Tags",
|
||||
options = tags.map { it.name to it.slug },
|
||||
)
|
||||
|
||||
class TagFilterText : Filter.Text(
|
||||
name = "Tags",
|
||||
)
|
||||
|
||||
class DemographicFilter : CheckBoxGroup(
|
||||
name = "Demographic",
|
||||
options = listOf(
|
||||
"Shounen" to "1",
|
||||
"Josei" to "2",
|
||||
"Seinen" to "3",
|
||||
"Shoujo" to "4",
|
||||
"None" to "0",
|
||||
),
|
||||
)
|
||||
|
||||
class CreatedAtFilter : SelectFilter(
|
||||
name = "Created At",
|
||||
options = listOf(
|
||||
"" to "",
|
||||
"3 days ago" to "3",
|
||||
"7 days ago" to "7",
|
||||
"30 days ago" to "30",
|
||||
"3 months ago" to "90",
|
||||
"6 months ago" to "180",
|
||||
"1 year ago" to "365",
|
||||
"2 years ago" to "730",
|
||||
),
|
||||
)
|
||||
|
||||
class TypeFilter : CheckBoxGroup(
|
||||
name = "Type",
|
||||
options = listOf(
|
||||
"Manga" to "jp",
|
||||
"Manhwa" to "kr",
|
||||
"Manhua" to "cn",
|
||||
"Others" to "others",
|
||||
),
|
||||
)
|
||||
|
||||
class MinimumChaptersFilter : Filter.Text(
|
||||
name = "Minimum Chapters",
|
||||
)
|
||||
|
||||
class StatusFilter : SelectFilter(
|
||||
name = "Status",
|
||||
options = listOf(
|
||||
"" to "",
|
||||
"Ongoing" to "1",
|
||||
"Completed" to "2",
|
||||
"Cancelled" to "3",
|
||||
"Hiatus" to "4",
|
||||
),
|
||||
)
|
||||
|
||||
class ContentRatingFilter : SelectFilter(
|
||||
name = "Content Rating",
|
||||
options = listOf(
|
||||
"" to "",
|
||||
"Safe" to "safe",
|
||||
"Suggestive" to "suggestive",
|
||||
"Erotica" to "erotica",
|
||||
),
|
||||
)
|
||||
|
||||
class ReleaseFrom : SelectFilter(
|
||||
name = "Release From",
|
||||
options = buildList {
|
||||
add(("" to ""))
|
||||
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
|
||||
("$it" to it.toString())
|
||||
}
|
||||
add(("Before 1990" to "0"))
|
||||
},
|
||||
)
|
||||
|
||||
class ReleaseTo : SelectFilter(
|
||||
name = "Release To",
|
||||
options = buildList {
|
||||
add(("" to ""))
|
||||
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
|
||||
("$it" to it.toString())
|
||||
}
|
||||
add(("Before 1990" to "0"))
|
||||
},
|
||||
)
|
||||
@ -1,19 +0,0 @@
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@ -32,19 +32,6 @@
|
||||
<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" />
|
||||
|
||||
|
||||
@ -20,6 +20,6 @@ If you've setup the Remote Storage via WebView the Recent tab shows your recent,
|
||||
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.
|
||||
You can directly open a imgur or Cubari link in the extension or paste the url in cubari browse
|
||||
|
||||
[Uncomment this if needed]: <> (## Guides)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Cubari'
|
||||
extClass = '.CubariFactory'
|
||||
extVersionCode = 25
|
||||
extVersionCode = 26
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package eu.kanade.tachiyomi.extension.all.cubari
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
@ -12,7 +12,7 @@ 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 keiyoushi.utils.parseAs
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
@ -20,23 +20,18 @@ 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.HttpUrl.Companion.toHttpUrl
|
||||
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() {
|
||||
class Cubari(override val lang: String) : HttpSource() {
|
||||
|
||||
final override val name = "Cubari"
|
||||
override val name = "Cubari"
|
||||
|
||||
final override val baseUrl = "https://cubari.moe"
|
||||
override val baseUrl = "https://cubari.moe"
|
||||
|
||||
final override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
@ -48,18 +43,17 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add(
|
||||
private val cubariHeaders = super.headersBuilder()
|
||||
.set(
|
||||
"User-Agent",
|
||||
"(Android ${Build.VERSION.RELEASE}; " +
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}) " +
|
||||
"Tachiyomi/${AppInfo.getVersionName()} " +
|
||||
Build.ID,
|
||||
)
|
||||
}
|
||||
"Tachiyomi/${AppInfo.getVersionName()} ${Build.ID} " +
|
||||
"Keiyoushi",
|
||||
).build()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
return GET("$baseUrl/", cubariHeaders)
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
@ -72,12 +66,12 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
val result = response.parseAs<JsonArray>()
|
||||
return parseMangaList(result, SortType.UNPINNED)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
return GET("$baseUrl/", cubariHeaders)
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
@ -90,19 +84,22 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
val result = response.parseAs<JsonArray>()
|
||||
return parseMangaList(result, SortType.PINNED)
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response -> mangaDetailsParse(response, manga) }
|
||||
}
|
||||
|
||||
// Called when the series is loaded, or when opening in browser
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "$baseUrl${manga.url}"
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl${manga.url}", headers)
|
||||
return chapterListRequest(manga)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
@ -110,7 +107,7 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val result = response.parseAs<JsonObject>()
|
||||
return parseManga(result, manga)
|
||||
}
|
||||
|
||||
@ -126,17 +123,16 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
val source = urlComponents[2]
|
||||
val slug = urlComponents[3]
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
throw Exception("Unused")
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// Called after the request
|
||||
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
|
||||
val res = response.body.string()
|
||||
return parseChapterList(res, manga)
|
||||
return parseChapterList(response, manga)
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
@ -161,21 +157,20 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return when {
|
||||
chapter.url.contains("/chapter/") -> {
|
||||
GET("$baseUrl${chapter.url}", headers)
|
||||
GET("$baseUrl${chapter.url}", cubariHeaders)
|
||||
}
|
||||
else -> {
|
||||
val url = chapter.url.split("/")
|
||||
val source = url[2]
|
||||
val slug = url[3]
|
||||
|
||||
GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun directPageListParse(response: Response): List<Page> {
|
||||
val res = response.body.string()
|
||||
val pages = json.parseToJsonElement(res).jsonArray
|
||||
val pages = response.parseAs<JsonArray>()
|
||||
|
||||
return pages.mapIndexed { i, jsonEl ->
|
||||
val page = if (jsonEl is JsonObject) {
|
||||
@ -189,7 +184,7 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
|
||||
val jsonObj = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val jsonObj = response.parseAs<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)
|
||||
@ -222,23 +217,29 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stub
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
throw Exception("Unused")
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PROXY_PREFIX) -> {
|
||||
val trimmedQuery = query.removePrefix(PROXY_PREFIX)
|
||||
// handle direct links or old cubari:source/id format
|
||||
query.startsWith("https://") || query.startsWith("cubari:") -> {
|
||||
val (source, slug) = deepLinkHandler(query)
|
||||
// Only tag for recently read on search
|
||||
client.newBuilder()
|
||||
.addInterceptor(RemoteStorageUtils.TagInterceptor())
|
||||
.build()
|
||||
.newCall(proxySearchRequest(trimmedQuery))
|
||||
.newCall(GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
proxySearchParse(response, trimmedQuery)
|
||||
val result = response.parseAs<JsonObject>()
|
||||
val manga = SManga.create().apply {
|
||||
url = "/read/$source/$slug"
|
||||
}
|
||||
val mangaList = listOf(parseManga(result, manga))
|
||||
|
||||
MangasPage(mangaList, false)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@ -259,18 +260,57 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/", headers)
|
||||
return GET("$baseUrl/", cubariHeaders)
|
||||
}
|
||||
|
||||
private fun proxySearchRequest(query: String): Request {
|
||||
try {
|
||||
val queryFragments = query.split("/")
|
||||
val source = queryFragments[0]
|
||||
val slug = queryFragments[1]
|
||||
private fun deepLinkHandler(query: String): Pair<String, String> {
|
||||
return if (query.startsWith("cubari:")) { // legacy cubari:source/slug format
|
||||
val queryFragments = query.substringAfter("cubari:").split("/", limit = 2)
|
||||
queryFragments[0] to queryFragments[1]
|
||||
} else { // direct url searching
|
||||
val url = query.toHttpUrl()
|
||||
val host = url.host
|
||||
val pathSegments = url.pathSegments
|
||||
|
||||
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
|
||||
} catch (e: Exception) {
|
||||
throw Exception(SEARCH_FALLBACK_MSG)
|
||||
if (
|
||||
host.endsWith("imgur.com") &&
|
||||
pathSegments.size >= 2 &&
|
||||
pathSegments[0] in listOf("a", "gallery")
|
||||
) {
|
||||
"imgur" to pathSegments[1]
|
||||
} else if (
|
||||
host.endsWith("reddit.com") &&
|
||||
pathSegments.size >= 2 &&
|
||||
pathSegments[0] == "gallery"
|
||||
) {
|
||||
"reddit" to pathSegments[1]
|
||||
} else if (
|
||||
host == "imgchest.com" &&
|
||||
pathSegments.size >= 2 &&
|
||||
pathSegments[0] == "p"
|
||||
) {
|
||||
"imgchest" to pathSegments[1]
|
||||
} else if (
|
||||
host.endsWith("catbox.moe") &&
|
||||
pathSegments.size >= 2 &&
|
||||
pathSegments[0] == "c"
|
||||
) {
|
||||
"catbox" to pathSegments[1]
|
||||
} else if (
|
||||
host.endsWith("cubari.moe") &&
|
||||
pathSegments.size >= 3
|
||||
) {
|
||||
pathSegments[1] to pathSegments[2]
|
||||
} else if (
|
||||
host.endsWith(".githubusercontent.com")
|
||||
) {
|
||||
val src = host.substringBefore(".")
|
||||
val path = url.encodedPath
|
||||
|
||||
"gist" to Base64.encodeToString("$src$path".toByteArray(), Base64.NO_PADDING)
|
||||
} else {
|
||||
throw Exception(SEARCH_FALLBACK_MSG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -279,7 +319,7 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
private fun searchMangaParse(response: Response, query: String): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
val result = response.parseAs<JsonArray>()
|
||||
|
||||
val filterList = result.asSequence()
|
||||
.map { it as JsonObject }
|
||||
@ -289,23 +329,14 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
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
|
||||
private fun parseChapterList(response: Response, manga: SManga): List<SChapter> {
|
||||
val jsonObj = response.parseAs<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
|
||||
@ -327,13 +358,7 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
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)
|
||||
0L
|
||||
}
|
||||
|
||||
name = buildString {
|
||||
@ -351,8 +376,6 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
}
|
||||
|
||||
seriesPrefsEditor.apply()
|
||||
|
||||
return chapterList.sortedByDescending { it.chapter_number }
|
||||
}
|
||||
|
||||
@ -375,16 +398,6 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
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
|
||||
@ -413,11 +426,10 @@ open class Cubari(override val lang: String) : HttpSource() {
|
||||
}
|
||||
|
||||
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>?"
|
||||
const val SEARCH_FALLBACK_MSG = "Please enter a valid Cubari URL"
|
||||
|
||||
enum class SortType {
|
||||
PINNED,
|
||||
|
||||
@ -11,59 +11,20 @@ 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)
|
||||
}
|
||||
}
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", intent.data.toString())
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("CubariUrlActivity", "Unable to find activity", e)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'E-Hentai'
|
||||
extClass = '.EHFactory'
|
||||
extVersionCode = 25
|
||||
extVersionCode = 26
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -351,7 +351,29 @@ abstract class EHentai(
|
||||
|
||||
override fun pageListParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
override fun imageUrlParse(response: Response): String = response.asJsoup().select("#img").attr("abs:src")
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
return imageUrlParse(response, true)
|
||||
}
|
||||
|
||||
private fun imageUrlParse(response: Response, isGetBakImageUrl: Boolean): String {
|
||||
val doc = response.asJsoup()
|
||||
val imgUrl = doc.select("#img").attr("abs:src")
|
||||
|
||||
if (!isGetBakImageUrl) {
|
||||
return imgUrl
|
||||
}
|
||||
|
||||
// from https://github.com/Miuzarte/EHentai-go/blob/dd9a24adb13300c028c35f53b9eff31b51966def/query.go#L695
|
||||
val loadfail = doc.selectFirst("#loadfail") ?: return imgUrl
|
||||
val onclick = loadfail.attr("onclick")
|
||||
val nlValue = Regex("nl\\('(.+?)'\\)").find(onclick)?.groupValues?.get(1)
|
||||
if (nlValue.isNullOrEmpty()) return imgUrl
|
||||
|
||||
val bakUrl = response.request.url.newBuilder()
|
||||
.addQueryParameter("nl", nlValue)
|
||||
.toString()
|
||||
return "$imgUrl#$bakUrl"
|
||||
}
|
||||
|
||||
private val cookiesHeader by lazy {
|
||||
val cookies = mutableMapOf<String, String>()
|
||||
@ -398,6 +420,25 @@ abstract class EHentai(
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val result = runCatching { chain.proceed(request) }
|
||||
val bakUrl = request.url.fragment
|
||||
?: return@addInterceptor result.getOrThrow()
|
||||
|
||||
if (result.isFailure || result.getOrNull()?.isSuccessful != true) {
|
||||
result.getOrNull()?.close()
|
||||
val newRequest = GET(bakUrl, headers)
|
||||
val newImageUrl = imageUrlParse(chain.proceed(newRequest), false)
|
||||
val newImageRequest = request.newBuilder()
|
||||
.url(newImageUrl)
|
||||
.build()
|
||||
|
||||
chain.proceed(newImageRequest)
|
||||
} else {
|
||||
result.getOrThrow()
|
||||
}
|
||||
}
|
||||
.addInterceptor { chain ->
|
||||
val newReq = chain
|
||||
.request()
|
||||
|
||||
8
src/all/hdoujin/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'HDoujin'
|
||||
extClass = '.HDoujinFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/hdoujin/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/all/hdoujin/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/all/hdoujin/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/all/hdoujin/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/all/hdoujin/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
@ -0,0 +1,195 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hdoujin
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
private val dateFormat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
|
||||
|
||||
@Serializable
|
||||
class MangaDetail(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val title: String,
|
||||
val title_short: String?,
|
||||
val created_at: Long = 0L,
|
||||
val updated_at: Long?,
|
||||
val subtitle: String?,
|
||||
val subtitle_short: String?,
|
||||
val thumbnails: Thumbnails,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
class Tag(
|
||||
val name: String,
|
||||
val namespace: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Thumbnail(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Thumbnails(
|
||||
val base: String,
|
||||
val main: Thumbnail,
|
||||
val entries: List<Thumbnail>,
|
||||
)
|
||||
fun toSManga() = SManga.create().apply {
|
||||
val artists = mutableListOf<String>()
|
||||
val circles = mutableListOf<String>()
|
||||
val parodies = mutableListOf<String>()
|
||||
val characters = mutableListOf<String>()
|
||||
val females = mutableListOf<String>()
|
||||
val males = mutableListOf<String>()
|
||||
val mixed = mutableListOf<String>()
|
||||
val language = mutableListOf<String>()
|
||||
val other = mutableListOf<String>()
|
||||
val uploaders = mutableListOf<String>()
|
||||
val tags = mutableListOf<String>()
|
||||
this@MangaDetail.tags.forEach { tag ->
|
||||
when (tag.namespace) {
|
||||
1 -> artists.add(tag.name)
|
||||
2 -> circles.add(tag.name)
|
||||
3 -> parodies.add(tag.name)
|
||||
5 -> characters.add(tag.name)
|
||||
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
|
||||
8 -> males.add(tag.name + " ♂")
|
||||
9 -> females.add(tag.name + " ♀")
|
||||
10 -> mixed.add(tag.name)
|
||||
11 -> language.add(tag.name)
|
||||
12 -> other.add(tag.name)
|
||||
else -> tags.add(tag.name)
|
||||
}
|
||||
}
|
||||
|
||||
var appended = false
|
||||
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
|
||||
|
||||
thumbnail_url = thumbnails.base + thumbnails.main.path
|
||||
|
||||
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
|
||||
artist = artists.joinToString { it.capitalizeEach() }
|
||||
genre = (artists + circles + parodies + characters + tags + females + males + mixed + other).joinToString { it.capitalizeEach() }
|
||||
description = buildString {
|
||||
circles.joinAndCapitalizeEach()?.let {
|
||||
append("Circles: ", it, "\n")
|
||||
}
|
||||
uploaders.joinAndCapitalizeEach()?.let {
|
||||
append("Uploaders: ", it, "\n")
|
||||
}
|
||||
parodies.joinAndCapitalizeEach()?.let {
|
||||
append("Parodies: ", it, "\n")
|
||||
}
|
||||
characters.joinAndCapitalizeEach()?.let {
|
||||
append("Characters: ", it, "\n")
|
||||
}
|
||||
|
||||
if (appended) append("\n")
|
||||
|
||||
try {
|
||||
append("Posted: ", dateFormat.format(created_at), "\n")
|
||||
} catch (_: Exception) {}
|
||||
|
||||
append("Pages: ", thumbnails.entries.size, "\n\n")
|
||||
|
||||
if (!subtitle.isNullOrBlank() || !subtitle_short.isNullOrBlank()) {
|
||||
append("Alternative Title(s): ", mutableSetOf(subtitle, subtitle_short).filter { !it.isNullOrBlank() }.joinToString { "\n- $it" }, "\n\n")
|
||||
}
|
||||
}
|
||||
status = SManga.COMPLETED
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
initialized = true
|
||||
}
|
||||
|
||||
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
|
||||
s.replaceFirstChar { sr ->
|
||||
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
|
||||
return this.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Data(
|
||||
val `0`: DataKey,
|
||||
val `780`: DataKey? = null,
|
||||
val `980`: DataKey? = null,
|
||||
val `1280`: DataKey? = null,
|
||||
val `1600`: DataKey? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class DataKey(
|
||||
val id: Int? = null,
|
||||
val size: Double = 0.0,
|
||||
val key: String? = null,
|
||||
) {
|
||||
fun readableSize() = when {
|
||||
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
|
||||
size >= 100 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0))} MB"
|
||||
size >= 1000 -> "${"%.2f".format(size / (1000.0))} kB"
|
||||
else -> "$size B"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MangaData(
|
||||
val data: Data,
|
||||
) {
|
||||
fun size(quality: String): String {
|
||||
val dataKey = when (quality) {
|
||||
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
|
||||
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
|
||||
"980" -> data.`980` ?: data.`1280` ?: data.`0`
|
||||
"780" -> data.`780` ?: data.`980` ?: data.`0`
|
||||
else -> data.`0`
|
||||
}
|
||||
return dataKey.readableSize()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Entries(
|
||||
val entries: List<Entry>,
|
||||
val limit: Int,
|
||||
val page: Int,
|
||||
val total: Int,
|
||||
) {
|
||||
@Serializable
|
||||
class Entry(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val thumbnail: Thumbnail,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = "$id/$key"
|
||||
title = this@Entry.title
|
||||
thumbnail_url = thumbnail.path
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Thumbnail(
|
||||
val path: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ImagesInfo(
|
||||
val base: String,
|
||||
val entries: List<ImagePath>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ImagePath(
|
||||
val path: String,
|
||||
)
|
||||
@ -0,0 +1,65 @@
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
SelectFilter("Sort by", getSortsList),
|
||||
CategoryFilter("Categories"),
|
||||
Filter.Separator(),
|
||||
TagType("Tags Include Type", "i"),
|
||||
TagType("Tags Exclude Type", "e"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
Filter.Header("Prepend with dash (-) to exclude"),
|
||||
TextFilter("Tags", "tag"),
|
||||
TextFilter("Male Tags", "male"),
|
||||
TextFilter("Female Tags", "female"),
|
||||
TextFilter("Mixed Tags", "mixed"),
|
||||
TextFilter("Other Tags", "other"),
|
||||
Filter.Separator(),
|
||||
TextFilter("Artists", "artist"),
|
||||
TextFilter("Parodies", "parody"),
|
||||
TextFilter("Characters", "character"),
|
||||
Filter.Separator(),
|
||||
TextFilter("Uploader", "reason"),
|
||||
TextFilter("Circles", "circle"),
|
||||
TextFilter("Languages", "language"),
|
||||
Filter.Separator(),
|
||||
Filter.Header("Filter by pages, for example: (>20)"),
|
||||
TextFilter("Pages", "pages"),
|
||||
)
|
||||
}
|
||||
|
||||
class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
|
||||
|
||||
internal class CategoryFilter(name: String) :
|
||||
Filter.Group<CheckBoxFilter>(
|
||||
name,
|
||||
listOf(
|
||||
Pair("Manga", 2),
|
||||
Pair("Doujinshi", 4),
|
||||
Pair("Illustration", 8),
|
||||
).map { CheckBoxFilter(it.first, it.second, true) },
|
||||
)
|
||||
|
||||
internal class TagType(title: String, val type: String) : Filter.Select<String>(
|
||||
title,
|
||||
arrayOf("AND", "OR"),
|
||||
)
|
||||
|
||||
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
||||
|
||||
internal open class SelectFilter(name: String, val vals: List<Pair<String, String>>, state: Int = 2) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
||||
val selected get() = vals[state].second.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Title", "2"),
|
||||
Pair("Pages", "3"),
|
||||
Pair("Date", ""),
|
||||
Pair("Views", "8"),
|
||||
Pair("Favourites", "9"),
|
||||
Pair("Popular This Week", "popular"),
|
||||
)
|
||||
@ -0,0 +1,397 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hdoujin
|
||||
|
||||
import CategoryFilter
|
||||
import SelectFilter
|
||||
import TagType
|
||||
import TextFilter
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.extension.all.hdoujin.Entries.Entry
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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 getFilters
|
||||
import keiyoushi.utils.getPreferences
|
||||
import keiyoushi.utils.jsonInstance
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class HDoujin(
|
||||
override val lang: String,
|
||||
private val siteLang: String = lang,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val name = "HDoujin"
|
||||
|
||||
override val supportsLatest = true
|
||||
private val preferences = getPreferences()
|
||||
private fun quality() = preferences.getString(PREF_IMAGE_RES, "1280")!!
|
||||
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
|
||||
private fun alwaysIncludeTags() = preferences.getString(PREF_INCLUDE_TAGS, "")
|
||||
private fun alwaysExcludeTags() = preferences.getString(PREF_EXCLUDE_TAGS, "")
|
||||
private fun getTagsPreference(): String {
|
||||
val include = alwaysIncludeTags()
|
||||
?.split(",")
|
||||
?.map(String::trim)
|
||||
?.filter(String::isNotBlank)
|
||||
|
||||
val exclude = alwaysExcludeTags()
|
||||
?.split(",")
|
||||
?.map(String::trim)
|
||||
?.filter(String::isNotBlank)
|
||||
?.map { "-$it" }
|
||||
|
||||
val tags: List<String> = include?.plus(exclude ?: emptyList()) ?: exclude?.plus(include ?: emptyList()) ?: emptyList()
|
||||
if (tags.isNotEmpty()) {
|
||||
val tagGroups: Map<String, Set<String>> = tags
|
||||
.groupBy {
|
||||
val tag = it.removePrefix("-")
|
||||
val parts = tag.split(":", limit = 2)
|
||||
if (parts.size == 2 && parts[0].isNotBlank()) parts[0] else "tag"
|
||||
}
|
||||
.mapValues { (_, values) ->
|
||||
values.mapTo(mutableSetOf()) {
|
||||
val tag = it.removePrefix("-").split(":").last().trim()
|
||||
if (it.startsWith("-")) "-$tag" else tag
|
||||
}
|
||||
}
|
||||
|
||||
return tagGroups.entries.joinToString(" ") { (key, values) ->
|
||||
"$key:\"${values.joinToString(",")}\""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
override val baseUrl: String = "https://hdoujin.org"
|
||||
private val baseApiUrl: String = "https://api.hdoujin.org"
|
||||
private val bookApiUrl: String = "$baseApiUrl/books"
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
.set("Origin", baseUrl)
|
||||
|
||||
private val context: Application by injectLazy()
|
||||
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||
private var _clearance: String? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun getClearance(): String? {
|
||||
_clearance?.also { return it }
|
||||
val latch = CountDownLatch(1)
|
||||
handler.post {
|
||||
val webview = WebView(context)
|
||||
with(webview.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
blockNetworkImage = true
|
||||
}
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view!!.evaluateJavascript("window.localStorage.getItem('clearance')") { clearance ->
|
||||
webview.stopLoading()
|
||||
webview.destroy()
|
||||
_clearance = clearance.takeUnless { it == "null" }?.removeSurrounding("\"")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
webview.loadDataWithBaseURL("$baseUrl/", " ", "text/html", null, null)
|
||||
}
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
return _clearance
|
||||
}
|
||||
private val clearanceClient = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val url = request.url
|
||||
val clearance = getClearance()
|
||||
?: throw IOException("Open webview to refresh token")
|
||||
|
||||
val newUrl = url.newBuilder()
|
||||
.setQueryParameter("crt", clearance)
|
||||
.build()
|
||||
val newRequest = request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
|
||||
if (response.code !in listOf(400, 403)) {
|
||||
return@addInterceptor response
|
||||
}
|
||||
response.close()
|
||||
_clearance = null
|
||||
throw IOException("Open webview to refresh token")
|
||||
}
|
||||
.rateLimit(3)
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET(
|
||||
bookApiUrl.toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("sort", "8")
|
||||
addQueryParameter("page", page.toString())
|
||||
|
||||
val tags = getTagsPreference()
|
||||
val terms: MutableList<String> = mutableListOf()
|
||||
if (lang != "all") terms += "language:\"^$siteLang\""
|
||||
if (tags.isNotBlank()) terms += tags
|
||||
|
||||
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||
}.build(),
|
||||
headers,
|
||||
)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<Entries>()
|
||||
|
||||
with(data) {
|
||||
return MangasPage(
|
||||
mangas = entries.map(Entry::toSManga),
|
||||
hasNextPage = limit * page < total,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET(
|
||||
bookApiUrl.toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("page", page.toString())
|
||||
|
||||
val tags = getTagsPreference()
|
||||
val terms: MutableList<String> = mutableListOf()
|
||||
if (lang != "all") terms += "language:\"^$siteLang\""
|
||||
if (tags.isNotBlank()) terms += tags
|
||||
|
||||
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||
}.build(),
|
||||
headers,
|
||||
)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = bookApiUrl.toHttpUrl().newBuilder().apply {
|
||||
val terms = mutableListOf(query.trim())
|
||||
|
||||
if (lang != "all") terms += "language:\"^$siteLang$\""
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SelectFilter -> {
|
||||
val value = filter.selected
|
||||
if (value == "popular") {
|
||||
addPathSegment(value)
|
||||
} else {
|
||||
addQueryParameter("sort", value)
|
||||
}
|
||||
}
|
||||
|
||||
is CategoryFilter -> {
|
||||
val activeFilter = filter.state.filter { it.state }
|
||||
if (activeFilter.isNotEmpty()) {
|
||||
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
|
||||
}
|
||||
}
|
||||
|
||||
is TextFilter -> {
|
||||
if (filter.state.isNotEmpty()) {
|
||||
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
|
||||
if (tags.isNotBlank()) {
|
||||
terms += "${filter.type}:${if (filter.type == "pages") tags else "\"$tags\""}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TagType -> {
|
||||
if (filter.state > 0) {
|
||||
addQueryParameter(
|
||||
filter.type,
|
||||
when {
|
||||
filter.type == "i" && filter.state == 0 -> ""
|
||||
filter.type == "e" && filter.state == 0 -> "1"
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
if (query.isNotEmpty()) terms.add("title:\"$query\"")
|
||||
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||
addQueryParameter("page", page.toString())
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
override fun getFilterList(): FilterList = getFilters()
|
||||
|
||||
private fun getImagesByMangaData(entry: MangaData, entryId: String, entryKey: String): Pair<ImagesInfo, String> {
|
||||
val data = entry.data
|
||||
fun getIPK(
|
||||
ori: DataKey?,
|
||||
alt1: DataKey?,
|
||||
alt2: DataKey?,
|
||||
alt3: DataKey?,
|
||||
alt4: DataKey?,
|
||||
): Pair<Int?, String?> {
|
||||
return Pair(
|
||||
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
|
||||
ori?.key ?: alt1?.key ?: alt2?.key ?: alt3?.key ?: alt4?.key,
|
||||
)
|
||||
}
|
||||
val (id, public_key) = when (quality()) {
|
||||
"1600" -> getIPK(data.`1600`, data.`1280`, data.`0`, data.`980`, data.`780`)
|
||||
"1280" -> getIPK(data.`1280`, data.`1600`, data.`0`, data.`980`, data.`780`)
|
||||
"980" -> getIPK(data.`980`, data.`1280`, data.`0`, data.`1600`, data.`780`)
|
||||
"780" -> getIPK(data.`780`, data.`980`, data.`0`, data.`1280`, data.`1600`)
|
||||
else -> getIPK(data.`0`, data.`1600`, data.`1280`, data.`980`, data.`780`)
|
||||
}
|
||||
|
||||
if (id == null || public_key == null) {
|
||||
throw Exception("No Images Found")
|
||||
}
|
||||
|
||||
val realQuality = when (id) {
|
||||
data.`1600`?.id -> "1600"
|
||||
data.`1280`?.id -> "1280"
|
||||
data.`980`?.id -> "980"
|
||||
data.`780`?.id -> "780"
|
||||
else -> "0"
|
||||
}
|
||||
|
||||
val imagesResponse = clearanceClient.newCall(GET("$bookApiUrl/data/$entryId/$entryKey/$id/$public_key/$realQuality", headers)).execute()
|
||||
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
|
||||
return images
|
||||
}
|
||||
|
||||
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
|
||||
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
GET("$bookApiUrl/detail/${manga.url}", headers)
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val mangaDetail = response.parseAs<MangaDetail>()
|
||||
with(mangaDetail) {
|
||||
return toSManga().apply {
|
||||
setUrlWithoutDomain("${mangaDetail.id}/${mangaDetail.key}")
|
||||
title = if (remadd()) {
|
||||
title_short
|
||||
?: mangaDetail.title.shortenTitle()
|
||||
} else {
|
||||
mangaDetail.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
|
||||
override fun chapterListRequest(manga: SManga) = GET("$bookApiUrl/detail/${manga.url}", headers)
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val manga = response.parseAs<MangaDetail>()
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
url = "${manga.id}/${manga.key}"
|
||||
date_upload = (manga.updated_at ?: manga.created_at)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
POST("$bookApiUrl/detail/${chapter.url}", headers)
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return clearanceClient.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
pageListParse(response)
|
||||
}
|
||||
}
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val mangaData = response.parseAs<MangaData>()
|
||||
val url = response.request.url.toString()
|
||||
val matches = Regex("""/detail/(\d+)/([a-z\d]+)""").find(url)
|
||||
if (matches == null || matches.groupValues.size < 3) return emptyList()
|
||||
val imagesInfo = getImagesByMangaData(mangaData, matches.groupValues[1], matches.groupValues[2])
|
||||
|
||||
return imagesInfo.first.entries.mapIndexed { index, image ->
|
||||
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!, headers)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T {
|
||||
return jsonInstance.decodeFromString(body.string())
|
||||
}
|
||||
|
||||
// Settings
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_IMAGE_RES
|
||||
title = "Image Resolution"
|
||||
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
|
||||
entryValues = arrayOf("780", "980", "1280", "1600", "0")
|
||||
summary = "%s"
|
||||
setDefaultValue("1280")
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_REM_ADD
|
||||
title = "Remove additional information in title"
|
||||
summary = "Remove anything in brackets from manga titles.\n" +
|
||||
"Reload manga to apply changes to loaded manga."
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_INCLUDE_TAGS
|
||||
title = "Tags to include from browse/search"
|
||||
summary = "Separate tags with commas (,).\n" +
|
||||
"Excluding: ${alwaysIncludeTags()}"
|
||||
}.also(screen::addPreference)
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_EXCLUDE_TAGS
|
||||
title = "Tags to exclude from browse/search"
|
||||
summary = "Separate tags with commas (,). Supports tag types (females, male, etc), defaults to 'tag' if not specified.\n" +
|
||||
"Example: 'ai generated, female:hairy, male:hairy'\n" +
|
||||
"Excluding: ${alwaysExcludeTags()}"
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
companion object {
|
||||
private const val PREF_REM_ADD = "pref_remove_additional"
|
||||
private const val PREF_IMAGE_RES = "pref_image_quality"
|
||||
private const val PREF_INCLUDE_TAGS = "pref_include_tags"
|
||||
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.extension.all.hdoujin
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class HDoujinFactory : SourceFactory {
|
||||
override fun createSources() = listOf(
|
||||
HDoujin("all"),
|
||||
HDoujin("en", "english"),
|
||||
HDoujin("ja", "japanese"),
|
||||
HDoujin("kr", "korean"),
|
||||
HDoujin("zh", "chinese"),
|
||||
)
|
||||
}
|
||||
@ -3,7 +3,7 @@ ext {
|
||||
extClass = '.HentaiFoxFactory'
|
||||
themePkg = 'galleryadults'
|
||||
baseUrl = 'https://hentaifox.com'
|
||||
overrideVersionCode = 6
|
||||
overrideVersionCode = 7
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -143,6 +143,7 @@ class HentaiFox(
|
||||
}
|
||||
return xhrHeaders.newBuilder()
|
||||
.add("X-Csrf-Token", csrfToken)
|
||||
.add("Referer", "$baseUrl/")
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@ -1,117 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.kanjiku
|
||||
|
||||
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.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
class Kanjiku(
|
||||
override val lang: String,
|
||||
subDomain: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val name = "Kanjiku"
|
||||
override val baseUrl = "https://${subDomain}kanjiku.net"
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/mangas", headers)
|
||||
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest", headers)
|
||||
|
||||
override fun popularMangaSelector(): String = ".manga_box"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
title = element.selectFirst(".manga_title")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val mangas = response.asJsoup().select(".manga_overview_box_headline a").map { element ->
|
||||
SManga.create().apply {
|
||||
var url = element.absUrl("href").toHttpUrl()
|
||||
if (url.pathSegments.last() == "") {
|
||||
// remove empty path segment
|
||||
url = url.newBuilder().removePathSegment(url.pathSegments.lastIndex).build()
|
||||
}
|
||||
setUrlWithoutDomain(url.toString())
|
||||
title = element.text()
|
||||
}
|
||||
}.distinctBy { it.url }
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return Observable.just(
|
||||
MangasPage(
|
||||
client.newCall(popularMangaRequest(page)).execute().asJsoup()
|
||||
.select(popularMangaSelector()).map { popularMangaFromElement(it) }
|
||||
.filter { query.lowercase() in it.title.lowercase() },
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
title = document.selectFirst(".manga_page_title")!!.text()
|
||||
description = document.selectFirst(".manga_description")?.text()
|
||||
thumbnail_url = document.selectFirst(".manga_page_picture")?.absUrl("src")
|
||||
status = when (
|
||||
document.selectFirst(".tags .tag_container_special .tag")?.absUrl("href")
|
||||
?.toHttpUrl()?.pathSegments?.last()
|
||||
) {
|
||||
"47" -> SManga.ONGOING
|
||||
"48" -> SManga.COMPLETED
|
||||
"49" -> SManga.ON_HIATUS
|
||||
"50" -> SManga.CANCELLED
|
||||
"51" -> SManga.LICENSED
|
||||
else -> SManga.UNKNOWN // using tag ids so that it works in all languages
|
||||
}
|
||||
genre = document.select(".tags .tag_container .tag").joinToString { it.text() }
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String = ".manga_chapter a"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
setUrlWithoutDomain(
|
||||
element.absUrl("href").toHttpUrl().run {
|
||||
newBuilder().setPathSegment(pathSegments.lastIndex, "0").build()
|
||||
}.toString(),
|
||||
)
|
||||
name = element.text()
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> =
|
||||
document.select(".container img").mapIndexed { index, element ->
|
||||
Page(index, imageUrl = element.absUrl("src"))
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesSelector(): String = throw UnsupportedOperationException()
|
||||
override fun searchMangaNextPageSelector(): String? = null
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
override fun latestUpdatesNextPageSelector(): String? = null
|
||||
override fun imageUrlParse(document: Document): String = ""
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.kanjiku
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class KanjikuFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
Kanjiku("de", ""),
|
||||
Kanjiku("en", "eng."),
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'SchaleNetwork'
|
||||
extClass = '.KoharuFactory'
|
||||
extVersionCode = 15
|
||||
extVersionCode = 17
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
@ -57,13 +58,23 @@ class Koharu(
|
||||
private val searchLang: String = "",
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
override val name = "SchaleNetwork"
|
||||
|
||||
override val baseUrl = "https://schale.network"
|
||||
override val baseUrl: String
|
||||
get() {
|
||||
val preferenceValue = preferences.getString(PREF_MIRROR, MIRROR_PREF_DEFAULT) ?: MIRROR_PREF_DEFAULT
|
||||
val mirror = preferenceValue.toIntOrNull()?.let { index ->
|
||||
mirrors[index.coerceAtMost(mirrors.lastIndex)]
|
||||
} ?: preferenceValue.takeIf { it in mirrors } ?: MIRROR_PREF_DEFAULT
|
||||
|
||||
return "https://$mirror"
|
||||
}
|
||||
|
||||
override val id = if (lang == "en") 1484902275639232927 else super.id
|
||||
|
||||
private val apiUrl = baseUrl.replace("://", "://api.")
|
||||
private val apiUrl = API_DOMAIN
|
||||
|
||||
private val apiBooksUrl = "$apiUrl/books"
|
||||
|
||||
@ -74,8 +85,6 @@ class Koharu(
|
||||
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
|
||||
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
private fun quality() = preferences.getString(PREF_IMAGERES, "1280")!!
|
||||
|
||||
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
|
||||
@ -457,6 +466,20 @@ class Koharu(
|
||||
// Settings
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_MIRROR
|
||||
title = "Preferred Mirror"
|
||||
entries = mirrors
|
||||
entryValues = mirrors
|
||||
setDefaultValue(MIRROR_PREF_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, "Restart the app to apply changes", Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_IMAGERES
|
||||
title = "Image Resolution"
|
||||
@ -488,6 +511,16 @@ class Koharu(
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_KEY_SEARCH = "id:"
|
||||
private const val PREF_MIRROR = "pref_mirror"
|
||||
private const val MIRROR_PREF_DEFAULT = "schale.network"
|
||||
private const val API_DOMAIN = "https://api.schale.network"
|
||||
private val mirrors = arrayOf(
|
||||
MIRROR_PREF_DEFAULT,
|
||||
"anchira.to",
|
||||
"gehenna.jp",
|
||||
"niyaniya.moe",
|
||||
"shupogaki.moe",
|
||||
)
|
||||
private const val PREF_IMAGERES = "pref_image_quality"
|
||||
private const val PREF_REM_ADD = "pref_remove_additional"
|
||||
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
|
||||
|
||||
27
src/all/luscious/AndroidManifest.xml
Normal file
@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.luscious.LusciousUrlActivity"
|
||||
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="www.luscious.net"
|
||||
android:pathPattern="/albums/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="members.luscious.net"
|
||||
android:pathPattern="/albums/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Luscious'
|
||||
extClass = '.LusciousFactory'
|
||||
extVersionCode = 22
|
||||
extVersionCode = 23
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@ -55,6 +56,11 @@ abstract class Luscious(
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder {
|
||||
return super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
}
|
||||
|
||||
override val client: OkHttpClient
|
||||
get() = network.cloudflareClient.newBuilder()
|
||||
.addNetworkInterceptor(rewriteOctetStream)
|
||||
@ -487,6 +493,12 @@ abstract class Luscious(
|
||||
client.newCall(buildAlbumInfoRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { MangasPage(listOf(detailsParse(it)), false) }
|
||||
} else if (query.startsWith("ALBUM:")) {
|
||||
val album = query.substringAfterLast("ALBUM:")
|
||||
val id = album.split("_").last()
|
||||
client.newCall(buildAlbumInfoRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { MangasPage(listOf(detailsParse(it)), false) }
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.extension.all.luscious
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://www.luscious.net/albums/xxxxxx intents and redirects them to
|
||||
* the main Tachiyomi process.
|
||||
*/
|
||||
class LusciousUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val album = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "ALBUM:$album")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("LusciousUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("LusciousUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
21
src/all/mangaball/AndroidManifest.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.mangaball.UrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
|
||||
<data android:host="mangaball.net" />
|
||||
<data android:scheme="https" />
|
||||
<data android:pathPattern="/title-detail/..*" />
|
||||
<data android:pathPattern="/chapter-detail/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
8
src/all/mangaball/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'Manga Ball'
|
||||
extClass = '.MangaBallFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/all/mangaball/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/all/mangaball/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
@ -0,0 +1,71 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangaball
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class SearchResponse(
|
||||
val data: List<SearchManga>,
|
||||
private val pagination: Pagination,
|
||||
) {
|
||||
@Serializable
|
||||
class Pagination(
|
||||
@SerialName("current_page")
|
||||
val currentPage: Int,
|
||||
@SerialName("last_page")
|
||||
val lastPage: Int,
|
||||
)
|
||||
|
||||
fun hasNextPage() = pagination.currentPage < pagination.lastPage
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class SearchManga(
|
||||
val url: String,
|
||||
val name: String,
|
||||
val cover: String,
|
||||
val isAdult: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ChapterListResponse(
|
||||
@SerialName("ALL_CHAPTERS")
|
||||
val chapters: List<ChapterContainer>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ChapterContainer(
|
||||
@SerialName("number_float")
|
||||
val number: Float,
|
||||
val translations: List<Chapter>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Chapter(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val language: String,
|
||||
val group: Group,
|
||||
val date: String,
|
||||
val volume: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Group(
|
||||
@SerialName("_id")
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Yoast(
|
||||
@SerialName("@graph")
|
||||
val graph: List<Graph>,
|
||||
) {
|
||||
@Serializable
|
||||
class Graph(
|
||||
@SerialName("@type")
|
||||
val type: String,
|
||||
val url: String? = null,
|
||||
)
|
||||
}
|
||||