Compare commits

..

No commits in common. "d9df2955e9298d0dbfb3f45304f76d7032a58182" and "2f2671757e1828cdd6aa708a6e052830e68df4a6" have entirely different histories.

1071 changed files with 6584 additions and 19843 deletions

View File

@ -10,54 +10,31 @@ or fixing it directly by submitting a Pull Request.
## Table of Contents
- [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)
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)
## Prerequisites
@ -715,46 +692,43 @@ with open(f"{package}/src/{source}.kt", "w") as f:
## Running
For local development, use the following run configuration to launch the app directly into the Browse panel.
To make local development more convenient, you can use the following run configuration to launch
Tachiyomi directly at the Browse panel:
![](https://i.imgur.com/6s2dvax.png)
![](https://i.imgur.com/STy0UFY.png)
Copy the following into `Launch Flags` for the Debug build of Mihon:
If you're running a Preview or debug build of Tachiyomi:
```
-W -S -n app.mihon.dev/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
-W -S -n eu.kanade.tachiyomi.debug/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
```
For other builds, replace `app.mihon.dev` with the corresponding package IDs:
- Release build: `app.mihon`
- Preview build: `app.mihon.debug`
And for a release build of Tachiyomi:
If the extension builds and runs successfully then the code changes should be ready to test in your local app.
```
-W -S -n eu.kanade.tachiyomi/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
```
> [!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.
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 can leverage the Android Debugger to 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 the app.
Instead, once you've built and installed your extension on the target device, use
`Attach Debugger to Android Process` to start debugging Tachiyomi.
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.
![](https://i.imgur.com/SUhdB52.png)
![](https://i.imgur.com/muhXyfu.png)
### Logs

View File

@ -18,7 +18,6 @@ android {
dependencies {
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
implementation(project(":core"))
}
tasks.register("printDependentExtensions") {

View File

@ -15,30 +15,12 @@ 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.
*/

View File

@ -0,0 +1,9 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 9
dependencies {
api(project(":lib:speedbinb"))
}

View File

@ -0,0 +1,129 @@
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]}/"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,244 +0,0 @@
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"
}
}

View File

@ -1,22 +0,0 @@
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,
)

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 6
baseVersionCode = 5

View File

@ -1,6 +1,7 @@
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
@ -30,6 +31,8 @@ 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,
@ -193,11 +196,31 @@ 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)
@ -421,5 +444,7 @@ 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"
}
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 4
baseVersionCode = 3

View File

@ -1,135 +0,0 @@
@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)

View File

@ -16,8 +16,13 @@ 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 keiyoushi.utils.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
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
@ -27,8 +32,10 @@ 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(
@ -41,15 +48,32 @@ abstract class HentaiHand(
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
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
}
}
// Popular
override fun popularMangaParse(response: Response): MangasPage {
val resp = response.parseAs<ResponseDto<List<MangaDto>>>()
val hasNextPage = !resp.next_page_url.isNullOrEmpty()
return MangasPage(resp.data.map { it.toSManga() }, hasNextPage)
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)
}
override fun popularMangaRequest(page: Int): Request {
@ -92,7 +116,9 @@ abstract class HentaiHand(
.subscribeOn(Schedulers.io())
.map { response ->
// Returns the first matched id, or null if there are no results
val idList = response.parseAs<ResponseDto<List<IdDto>>>().data.map { it.id }
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map {
it.jsonObject["id"]!!.jsonPrimitive.content
}
if (idList.isEmpty()) {
return@map null
} else {
@ -151,7 +177,33 @@ abstract class HentaiHand(
}
override fun mangaDetailsParse(response: Response): SManga {
return response.parseAs<MangaDetailsResponseDto>().toSMangaDetails()
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}" }
}
}
// Chapters
@ -168,13 +220,40 @@ abstract class HentaiHand(
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
return if (this.chapters) {
val slug = response.request.url.toString()
.substringAfter("/api/comics/")
.removeSuffix("/chapters")
response.parseAs<ChapterListResponseDto>().map { it.toSChapter(slug) }
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
}
}
}
} else {
listOf(response.parseAs<ChapterResponseDto>().toSChapter())
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
},
)
}
}
@ -185,7 +264,13 @@ abstract class HentaiHand(
return GET("$baseUrl/api/comics/$slug/images")
}
override fun pageListParse(response: Response): List<Page> = response.parseAs<PageListResponseDto>().toPageList()
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 imageUrlParse(response: Response): String = throw UnsupportedOperationException()
@ -193,10 +278,7 @@ abstract class HentaiHand(
protected fun authIntercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (username.isEmpty() or password.isEmpty()
// image request doesn't need token
or !request.url.toString().startsWith(baseUrl)
) {
if (username.isEmpty() or password.isEmpty()) {
return chain.proceed(request)
}
@ -222,7 +304,7 @@ abstract class HentaiHand(
}
try {
// Returns access token as a string, unless unparseable
return response.parseAs<LoginResponseDto>().auth.access_token
return json.parseToJsonElement(response.body.string()).jsonObject["auth"]!!.jsonObject["access-token"]!!.jsonPrimitive.content
} catch (e: IllegalArgumentException) {
throw IOException("Cannot parse login response body")
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 11
baseVersionCode = 10

View File

@ -107,7 +107,7 @@ class Chapter(
fun isLocked() = (isLocked == true) || (isTimeLocked == true)
fun toSChapter(mangaSlug: String?) = SChapter.create().apply {
val prefix = if (!isAccessible()) "🔒 " else ""
val prefix = if (isLocked()) "🔒 " else ""
val seriesSlug = mangaSlug ?: mangaPost.slug
url = "/series/$seriesSlug/$slug#$id"
name = "${prefix}Chapter $number"

View File

@ -57,12 +57,10 @@ 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(popularMangaSelector).mapNotNull {
val entries = document.select("aside a:has(img)").mapNotNull {
titleCache[it.absUrl("href").substringAfter("series/")]?.toSManga()
}
@ -173,7 +171,7 @@ abstract class Iken(
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = showLockedChapterPrefKey
title = "Show inaccessible chapters"
title = "Show locked chapters"
setDefaultValue(false)
}.also(screen::addPreference)
}

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 18
baseVersionCode = 17
dependencies {
api(project(":lib:i18n"))

View File

@ -215,9 +215,7 @@ 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.grid:has(>h1) > div > a:not([title='Status'])"
protected open val typeSelector: String = "div:has(span:containsOwn(Type)) ~ div"
protected open val genreSelector: String = "div:has(span:containsOwn(Type)) ~ div"
protected open val dateSelector: String = ".text-xs"
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
@ -228,16 +226,16 @@ abstract class Keyoapp(
author = document.selectFirst(authorSelector)?.text()
artist = document.selectFirst(artistSelector)?.text()
genre = buildList {
document.selectFirst(typeSelector)?.text()?.replaceFirstChar {
document.selectFirst(genreSelector)?.text()?.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(
Locale.ENGLISH,
Locale.getDefault(),
)
} else {
it.toString()
}
}.let(::add)
document.select(genreSelector).forEach { add(it.text()) }
document.select("div.grid:has(>h1) > div > a").forEach { add(it.text()) }
}.joinToString()
}

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 45
baseVersionCode = 44
dependencies {
api(project(":lib:cryptoaes"))

View File

@ -919,7 +919,7 @@ abstract class Madara(
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
parseRelativeDate(date)
}
WordSet("hace", "giờ", "phút", "giây").startsWith(date) -> {
WordSet("hace").startsWith(date) -> {
parseRelativeDate(date)
}
// Handle "jour" with a number before it

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 20
baseVersionCode = 19

View File

@ -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") + "#image-request"
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src")
}
/*
@ -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") + "#image-request"
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src")
val altNames = document.selectFirst(".detail h2")?.text()
?.split(',', ';')

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 9
baseVersionCode = 8

View File

@ -211,7 +211,7 @@ abstract class MangaBox(
}
}
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, div.list-comic-item-wrap"
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, .list-comic-item-wrap .list-story-item"
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)

View File

@ -1,9 +0,0 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

View File

@ -1,82 +0,0 @@
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,
)

View File

@ -1,103 +0,0 @@
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",
),
)

View File

@ -1,360 +0,0 @@
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
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 1
baseVersionCode = 4

View File

@ -0,0 +1,275 @@
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")
}
}

View File

@ -0,0 +1,158 @@
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()
}
}
}

View File

@ -1,5 +0,0 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

View File

@ -1,182 +0,0 @@
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)
}
}
}

View File

@ -1,157 +0,0 @@
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)
}

View File

@ -1,3 +0,0 @@
plugins {
id("lib-android")
}

View File

@ -1,171 +0,0 @@
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
}
}

View File

@ -1,41 +0,0 @@
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
}
}

View File

@ -1,37 +0,0 @@
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,
)

View File

@ -1,81 +0,0 @@
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
}
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'Bato.to'
extClass = '.BatoToFactory'
extVersionCode = 56
extVersionCode = 53
isNsfw = true
}

View File

@ -2,12 +2,7 @@ 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
@ -59,7 +54,15 @@ open class BatoTo(
override val name: String = "Bato.to"
override val baseUrl: String get() = getMirrorPref()
override var baseUrl: String = ""
get() {
val current = field
if (current.isNotEmpty()) {
return current
}
field = getMirrorPref()
return field
}
override val id: Long = when (lang) {
"zh-Hans" -> 2818874445640189582
@ -76,6 +79,10 @@ 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"
@ -92,49 +99,9 @@ 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 {
@ -165,14 +132,12 @@ 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).apply()
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).commit()
}
}
@ -196,9 +161,8 @@ open class BatoTo(
val manga = SManga.create()
val item = element.select("a.item-cover")
val imgurl = item.select("img").attr("abs:src")
manga.setUrlWithoutDomain(stripSeriesUrl(item.attr("href")))
manga.setUrlWithoutDomain(item.attr("href"))
manga.title = element.select("a.item-title").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = imgurl
return manga
}
@ -323,10 +287,9 @@ 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(stripSeriesUrl(infoElement.select("h3 a").attr("abs:href")))
manga.setUrlWithoutDomain(infoElement.select("h3 a").attr("abs:href"))
return MangasPage(listOf(manga), false)
}
@ -357,18 +320,16 @@ open class BatoTo(
private fun searchUtilsFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(stripSeriesUrl(element.select("td a").attr("href")))
manga.setUrlWithoutDomain(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(stripSeriesUrl(element.select(".position-relative a").attr("href")))
manga.setUrlWithoutDomain(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
}
@ -397,6 +358,8 @@ 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")!!
@ -417,19 +380,19 @@ open class BatoTo(
append(it.text().split('/').joinToString("\n") { "${it.trim()}" })
}
}.trim()
val cleanedTitle = originalTitle.cleanTitleIfNeeded()
val cleanedTitle = if (isRemoveTitleVersion()) {
originalTitle.replace(titleRegex, "").trim()
} else {
originalTitle
}
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 = if (originalTitle.trim() != cleanedTitle) {
listOf(originalTitle, description)
.joinToString("\n\n")
} else {
description
}
manga.description = description
manga.thumbnail_url = document.select("div.attr-cover img").attr("abs:src")
return manga
}
@ -473,9 +436,9 @@ open class BatoTo(
}
override fun chapterListRequest(manga: SManga): Request {
val id = seriesIdRegex.find(manga.url)
?.groups?.get(1)?.value?.trim()
return if (getAltChapterListPref() && !id.isNullOrBlank()) {
return if (getAltChapterListPref()) {
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
GET("$baseUrl/rss/series/$id.xml", headers)
} else if (manga.url.startsWith("http")) {
// Check if trying to use a deprecated mirror, force current mirror
@ -620,19 +583,6 @@ 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(),
@ -1088,94 +1038,50 @@ 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",
// 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",
"batocomic.com",
"batocomic.net",
"batocomic.org",
"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",
"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)
"dto.to",
"fto.to",
"hto.to",
"jto.to",
"mto.to",
"wto.to",
)
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)
}
}

View File

@ -1,9 +1,8 @@
ext {
extName = 'Comic Growl'
extClass = '.ComicGrowl'
themePkg = 'comiciviewer'
baseUrl = 'https://comic-growl.com'
overrideVersionCode = 7
extVersionCode = 7
isNsfw = false
}

View File

@ -1,9 +1,212 @@
package eu.kanade.tachiyomi.extension.all.comicgrowl
import eu.kanade.tachiyomi.multisrc.comiciviewer.ComiciViewer
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
class ComicGrowl : ComiciViewer(
"Comic Growl",
"https://comic-growl.com",
"all",
)
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"))
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.multisrc.comiciviewer
package eu.kanade.tachiyomi.extension.all.comicgrowl
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@ -10,37 +10,28 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
class ImageInterceptor : Interceptor {
object ImageDescrambler {
override fun intercept(chain: Interceptor.Chain): Response {
// Left-top corner position
private class TilePos(val x: Int, val y: Int)
/**
* Interceptor to descramble the image.
*/
fun interceptor(chain: Interceptor.Chain): Response {
val request = chain.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 response = chain.proceed(request)
val scramble = request.url.fragment ?: return response // return if no scramble fragment
val tiles = buildList {
scrambleData.drop(1).dropLast(1).replace(" ", "").split(",").forEach {
val scrambleInt = it.toInt()
add(TilePos(scrambleInt / 4, scrambleInt % 4))
scramble.split("-").forEachIndexed { index, s ->
val scrambleInt = s.toInt()
add(index, TilePos(scrambleInt / 4, scrambleInt % 4))
}
}
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
val descrambledImg =
unscrambleImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
val descrambledImg = drawDescrambledImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
val output = ByteArrayOutputStream()
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
@ -50,27 +41,20 @@ class ImageInterceptor : Interceptor {
return response.newBuilder().body(body).build()
}
private fun unscrambleImage(
rawImage: Bitmap,
width: Int,
height: Int,
tiles: List<TilePos>,
): Bitmap {
private fun drawDescrambledImage(rawImage: Bitmap, width: Int, height: Int, tiles: List<TilePos>): Bitmap {
// Prepare canvas
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,

View File

@ -0,0 +1,16 @@
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,
)

View File

@ -1,12 +0,0 @@
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")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,441 +0,0 @@
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"

View File

@ -1,74 +0,0 @@
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"),
)
}

View File

@ -1,139 +0,0 @@
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,
)
}
}

View File

@ -1,151 +0,0 @@
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"))
},
)

View File

@ -1,7 +1,7 @@
ext {
extName = 'Comico'
extClass = '.Comico'
extVersionCode = 1
extClass = '.ComicoFactory'
extVersionCode = 7
isNsfw = true
}

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.ja.comico
package eu.kanade.tachiyomi.extension.all.comico
import android.webkit.CookieManager
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
@ -30,16 +30,17 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class Comico : HttpSource() {
override val name = "コミコ"
override val baseUrl = "https://www.comico.jp"
override val lang = "ja"
private val apiUrl = "https://api.comico.jp"
override val id = 4991718230921189832
open class Comico(
final override val baseUrl: String,
final override val name: String,
private val langCode: String,
) : HttpSource() {
final override val supportsLatest = true
override val lang = langCode.substring(0, 2)
protected open val apiUrl = baseUrl.replace("www", "api")
private val json by injectLazy<Json>()
private val cookieManager by lazy { CookieManager.getInstance() }
@ -76,7 +77,7 @@ class Comico : HttpSource() {
).build()
override fun headersBuilder() = Headers.Builder()
.set("Accept-Language", lang)
.set("Accept-Language", langCode)
.set("User-Agent", userAgent)
.set("Referer", "$baseUrl/")

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.extension.all.comico
import eu.kanade.tachiyomi.source.SourceFactory
class ComicoFactory : SourceFactory {
open class PocketComics(langCode: String) :
Comico("https://www.pocketcomics.com", "POCKET COMICS", langCode)
class ComicoJP : Comico("https://www.comico.jp", "コミコ", "ja-JP")
class ComicoKR : Comico("https://www.comico.kr", "코미코", "ko-KR")
override fun createSources() = listOf(
PocketComics("en-US"),
PocketComics("zh-TW"),
ComicoJP(),
ComicoKR(),
)
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.ja.comico
package eu.kanade.tachiyomi.extension.all.comico
import kotlinx.serialization.Serializable

View File

@ -32,6 +32,19 @@
<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" />

View File

@ -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 or paste the url in cubari browse
You can directly open a imgur or Cubari link in the extension.
[Uncomment this if needed]: <> (## Guides)

View File

@ -1,7 +1,7 @@
ext {
extName = 'Cubari'
extClass = '.CubariFactory'
extVersionCode = 26
extVersionCode = 25
}
apply from: "$rootDir/common.gradle"

View File

@ -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 keiyoushi.utils.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
@ -20,18 +20,23 @@ import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class Cubari(override val lang: String) : HttpSource() {
open class Cubari(override val lang: String) : HttpSource() {
override val name = "Cubari"
final override val name = "Cubari"
override val baseUrl = "https://cubari.moe"
final override val baseUrl = "https://cubari.moe"
override val supportsLatest = true
final override val supportsLatest = true
private val json: Json by injectLazy()
override val client = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
@ -43,17 +48,18 @@ class Cubari(override val lang: String) : HttpSource() {
}
.build()
private val cubariHeaders = super.headersBuilder()
.set(
override fun headersBuilder() = Headers.Builder().apply {
add(
"User-Agent",
"(Android ${Build.VERSION.RELEASE}; " +
"${Build.MANUFACTURER} ${Build.MODEL}) " +
"Tachiyomi/${AppInfo.getVersionName()} ${Build.ID} " +
"Keiyoushi",
).build()
"Tachiyomi/${AppInfo.getVersionName()} " +
Build.ID,
)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/", cubariHeaders)
return GET("$baseUrl/", headers)
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
@ -66,12 +72,12 @@ class Cubari(override val lang: String) : HttpSource() {
}
override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<JsonArray>()
val result = json.parseToJsonElement(response.body.string()).jsonArray
return parseMangaList(result, SortType.UNPINNED)
}
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/", cubariHeaders)
return GET("$baseUrl/", headers)
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
@ -84,22 +90,19 @@ class Cubari(override val lang: String) : HttpSource() {
}
override fun popularMangaParse(response: Response): MangasPage {
val result = response.parseAs<JsonArray>()
val result = json.parseToJsonElement(response.body.string()).jsonArray
return parseMangaList(result, SortType.PINNED)
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
return client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response -> mangaDetailsParse(response, manga) }
}
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl${manga.url}"
}
// Called when the series is loaded, or when opening in browser
override fun mangaDetailsRequest(manga: SManga): Request {
return chapterListRequest(manga)
return GET("$baseUrl${manga.url}", headers)
}
override fun mangaDetailsParse(response: Response): SManga {
@ -107,7 +110,7 @@ class Cubari(override val lang: String) : HttpSource() {
}
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
val result = response.parseAs<JsonObject>()
val result = json.parseToJsonElement(response.body.string()).jsonObject
return parseManga(result, manga)
}
@ -123,16 +126,17 @@ class Cubari(override val lang: String) : HttpSource() {
val source = urlComponents[2]
val slug = urlComponents[3]
return GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
throw UnsupportedOperationException()
throw Exception("Unused")
}
// Called after the request
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
return parseChapterList(response, manga)
val res = response.body.string()
return parseChapterList(res, manga)
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
@ -157,20 +161,21 @@ class Cubari(override val lang: String) : HttpSource() {
override fun pageListRequest(chapter: SChapter): Request {
return when {
chapter.url.contains("/chapter/") -> {
GET("$baseUrl${chapter.url}", cubariHeaders)
GET("$baseUrl${chapter.url}", headers)
}
else -> {
val url = chapter.url.split("/")
val source = url[2]
val slug = url[3]
GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
GET("$baseUrl/read/api/$source/series/$slug/", headers)
}
}
}
private fun directPageListParse(response: Response): List<Page> {
val pages = response.parseAs<JsonArray>()
val res = response.body.string()
val pages = json.parseToJsonElement(res).jsonArray
return pages.mapIndexed { i, jsonEl ->
val page = if (jsonEl is JsonObject) {
@ -184,7 +189,7 @@ class Cubari(override val lang: String) : HttpSource() {
}
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
val jsonObj = response.parseAs<JsonObject>()
val jsonObj = json.parseToJsonElement(response.body.string()).jsonObject
val groups = jsonObj["groups"]!!.jsonObject
val groupMap = groups.entries.associateBy({ it.value.jsonPrimitive.content.ifEmpty { "default" } }, { it.key })
val chapterScanlator = chapter.scanlator ?: "default" // workaround for "" as group causing NullPointerException (#13772)
@ -217,29 +222,23 @@ class Cubari(override val lang: String) : HttpSource() {
}
}
// Stub
override fun pageListParse(response: Response): List<Page> {
throw UnsupportedOperationException()
throw Exception("Unused")
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
// handle direct links or old cubari:source/id format
query.startsWith("https://") || query.startsWith("cubari:") -> {
val (source, slug) = deepLinkHandler(query)
query.startsWith(PROXY_PREFIX) -> {
val trimmedQuery = query.removePrefix(PROXY_PREFIX)
// Only tag for recently read on search
client.newBuilder()
.addInterceptor(RemoteStorageUtils.TagInterceptor())
.build()
.newCall(GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders))
.newCall(proxySearchRequest(trimmedQuery))
.asObservableSuccess()
.map { response ->
val result = response.parseAs<JsonObject>()
val manga = SManga.create().apply {
url = "/read/$source/$slug"
}
val mangaList = listOf(parseManga(result, manga))
MangasPage(mangaList, false)
proxySearchParse(response, trimmedQuery)
}
}
else -> {
@ -260,57 +259,18 @@ class Cubari(override val lang: String) : HttpSource() {
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return GET("$baseUrl/", cubariHeaders)
return GET("$baseUrl/", headers)
}
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
private fun proxySearchRequest(query: String): Request {
try {
val queryFragments = query.split("/")
val source = queryFragments[0]
val slug = queryFragments[1]
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)
}
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
} catch (e: Exception) {
throw Exception(SEARCH_FALLBACK_MSG)
}
}
@ -319,7 +279,7 @@ class Cubari(override val lang: String) : HttpSource() {
}
private fun searchMangaParse(response: Response, query: String): MangasPage {
val result = response.parseAs<JsonArray>()
val result = json.parseToJsonElement(response.body.string()).jsonArray
val filterList = result.asSequence()
.map { it as JsonObject }
@ -329,14 +289,23 @@ 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(response: Response, manga: SManga): List<SChapter> {
val jsonObj = response.parseAs<JsonObject>()
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
val jsonObj = json.parseToJsonElement(payload).jsonObject
val groups = jsonObj["groups"]!!.jsonObject
val chapters = jsonObj["chapters"]!!.jsonObject
val seriesSlug = jsonObj["slug"]!!.jsonPrimitive.content
val seriesPrefs = Injekt.get<Application>().getSharedPreferences("source_${id}_updateTime:$seriesSlug", 0)
val seriesPrefsEditor = seriesPrefs.edit()
val chapterList = chapters.entries.flatMap { chapterEntry ->
val chapterNum = chapterEntry.key
@ -358,7 +327,13 @@ class Cubari(override val lang: String) : HttpSource() {
date_upload = if (releaseDate != null) {
releaseDate.jsonPrimitive.double.toLong() * 1000
} else {
0L
val currentTimeMillis = System.currentTimeMillis()
if (!seriesPrefs.contains(chapterNum)) {
seriesPrefsEditor.putLong(chapterNum, currentTimeMillis)
}
seriesPrefs.getLong(chapterNum, currentTimeMillis)
}
name = buildString {
@ -376,6 +351,8 @@ class Cubari(override val lang: String) : HttpSource() {
}
}
seriesPrefsEditor.apply()
return chapterList.sortedByDescending { it.chapter_number }
}
@ -398,6 +375,16 @@ 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
@ -426,10 +413,11 @@ 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 = "Please enter a valid Cubari URL"
const val SEARCH_FALLBACK_MSG = "Unable to parse. Is your query in the format of $PROXY_PREFIX<source>/<slug>?"
enum class SortType {
PINNED,

View File

@ -11,20 +11,59 @@ class CubariUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val host = intent?.data?.host
val pathSegments = intent?.data?.pathSegments
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", intent.data.toString())
putExtra("filter", packageName)
}
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)
}
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("CubariUrlActivity", "Unable to find activity", e)
if (query == null) {
Log.e("CubariUrlActivity", "Unable to parse URI from intent $intent")
finish()
exitProcess(1)
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("CubariUrlActivity", e.toString())
}
}
finish()
exitProcess(0)
}
private fun fromSource(source: String, pathSegments: List<String>): String? {
if (pathSegments.size >= 2) {
val id = pathSegments[1]
return "${Cubari.PROXY_PREFIX}$source/$id"
}
return null
}
private fun fromCubari(pathSegments: MutableList<String>): String? {
return if (pathSegments.size >= 3) {
val source = pathSegments[1]
val slug = pathSegments[2]
"${Cubari.PROXY_PREFIX}$source/$slug"
} else {
null
}
}
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'E-Hentai'
extClass = '.EHFactory'
extVersionCode = 26
extVersionCode = 25
isNsfw = true
}

View File

@ -351,29 +351,7 @@ abstract class EHentai(
override fun pageListParse(response: Response) = throw UnsupportedOperationException()
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"
}
override fun imageUrlParse(response: Response): String = response.asJsoup().select("#img").attr("abs:src")
private val cookiesHeader by lazy {
val cookies = mutableMapOf<String, String>()
@ -420,25 +398,6 @@ 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()

View File

@ -1,8 +0,0 @@
ext {
extName = 'HDoujin'
extClass = '.HDoujinFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,195 +0,0 @@
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,
)

View File

@ -1,65 +0,0 @@
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"),
)

View File

@ -1,397 +0,0 @@
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"
}
}

View File

@ -1,13 +0,0 @@
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"),
)
}

View File

@ -3,7 +3,7 @@ ext {
extClass = '.HentaiFoxFactory'
themePkg = 'galleryadults'
baseUrl = 'https://hentaifox.com'
overrideVersionCode = 7
overrideVersionCode = 6
isNsfw = true
}

View File

@ -143,7 +143,6 @@ class HentaiFox(
}
return xhrHeaders.newBuilder()
.add("X-Csrf-Token", csrfToken)
.add("Referer", "$baseUrl/")
.build()
}

View File

@ -1,6 +1,6 @@
ext {
extName = 'OTruyen'
extClass = '.OTruyen'
extName = 'Kanjiku'
extClass = '.KanjikuFactory'
extVersionCode = 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,117 @@
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 = ""
}

View File

@ -0,0 +1,11 @@
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."),
)
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'SchaleNetwork'
extClass = '.KoharuFactory'
extVersionCode = 17
extVersionCode = 15
isNsfw = true
}

View File

@ -7,7 +7,6 @@ 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
@ -58,23 +57,13 @@ class Koharu(
private val searchLang: String = "",
) : HttpSource(), ConfigurableSource {
private val preferences: SharedPreferences by getPreferencesLazy()
override val name = "SchaleNetwork"
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 baseUrl = "https://schale.network"
override val id = if (lang == "en") 1484902275639232927 else super.id
private val apiUrl = API_DOMAIN
private val apiUrl = baseUrl.replace("://", "://api.")
private val apiBooksUrl = "$apiUrl/books"
@ -85,6 +74,8 @@ 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)
@ -466,20 +457,6 @@ 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"
@ -511,16 +488,6 @@ 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"

View File

@ -1,27 +0,0 @@
<?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>

View File

@ -1,7 +1,7 @@
ext {
extName = 'Luscious'
extClass = '.LusciousFactory'
extVersionCode = 23
extVersionCode = 22
isNsfw = true
}

View File

@ -29,7 +29,6 @@ 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
@ -56,11 +55,6 @@ 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)
@ -493,12 +487,6 @@ 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)
}

Some files were not shown because too many files have changed in this diff Show More