Add Anchira (#162)

* Add Anchira

* Encode API decryption key

* Apply corrections

* Remove unused MessagePack library
This commit is contained in:
Fermín Cirella 2024-01-12 03:07:18 -03:00 committed by Draff
parent 6cc9041f10
commit cafe12c736
12 changed files with 552 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,13 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Anchira'
pkgNameSuffix = 'en.anchira'
extClass = '.Anchira'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@ -0,0 +1,337 @@
package eu.kanade.tachiyomi.extension.en.anchira
import android.annotation.SuppressLint
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.decodeBytes
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.getPathFromUrl
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.json
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.prepareTags
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
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.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.decodeFromStream
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.concurrent.TimeUnit
class Anchira : HttpSource(), ConfigurableSource {
override val name = "Anchira"
override val baseUrl = "https://anchira.to"
private val apiUrl = "$baseUrl/api/v1"
private val libraryUrl = "$apiUrl/library"
private val cdnUrl = "https://kisakisexo.xyz"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(3, 1, TimeUnit.SECONDS)
.addInterceptor { apiInterceptor(it) }
.build()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder() = super.headersBuilder().add("X-Requested-With", "XMLHttpRequest")
// Latest
override fun latestUpdatesRequest(page: Int) = GET("$libraryUrl?page=$page", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val data = decodeBytes<LibraryResponse>(response.body)
return MangasPage(
data.entries.map {
SManga.create().apply {
url = "/g/${it.id}/${it.key}"
title = it.title
thumbnail_url = "$cdnUrl/${it.id}/${it.key}/m/${it.cover.name}"
artist = it.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
author = it.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
genre = prepareTags(it.tags)
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
status = SManga.COMPLETED
}
}.toList(),
(data.page + 1) * data.limit < data.total,
)
}
// Popular
override fun popularMangaRequest(page: Int) = GET("$libraryUrl?sort=32&page=$page", headers)
override fun popularMangaParse(response: Response) = latestUpdatesParse(response)
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
var url = libraryUrl.toHttpUrl().newBuilder()
url.addQueryParameter("page", page.toString())
if (query.isNotBlank()) {
url.addQueryParameter("s", query)
}
filters.forEach { filter ->
when (filter) {
is CategoryGroup -> {
var sum = 0
filter.state.forEach { category ->
when (category.name) {
"Manga" -> if (category.state) sum = sum or 1
"Doujinshi" -> if (category.state) sum = sum or 2
"Illustration" -> if (category.state) sum = sum or 4
}
}
if (sum > 0) url.addQueryParameter("cat", sum.toString())
}
is SortFilter -> {
val sort = when (filter.state?.index) {
0 -> "1"
1 -> "2"
2 -> "4"
4 -> "32"
else -> ""
}
if (sort.isNotEmpty()) url.addQueryParameter("sort", sort)
if (filter.state?.ascending == true) url.addQueryParameter("order", "1")
}
is FavoritesFilter -> {
if (filter.state) {
if (!isLoggedIn()) {
throw IOException("No login cookie found")
}
url = url.toString().replace("library", "user/favorites").toHttpUrl()
.newBuilder()
}
}
else -> {}
}
}
return GET(url.build(), headers)
}
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
// Details
override fun mangaDetailsRequest(manga: SManga) =
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val data = decodeBytes<Entry>(response.body)
return SManga.create().apply {
url = "/g/${data.id}/${data.key}"
title = data.title
thumbnail_url =
"$cdnUrl/${data.id}/${data.key}/b/${data.data[data.thumbnailIndex].name}"
artist = data.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
author = data.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
genre = prepareTags(data.tags)
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
status = SManga.COMPLETED
}
}
override fun getMangaUrl(manga: SManga) = if (preferences.openSource) {
val id = manga.url.split("/").reversed()[1].toInt()
entryExtraData.find { it.id == id }?.url ?: "$baseUrl${manga.url}"
} else {
"$baseUrl${manga.url}"
}
// Chapter
override fun chapterListRequest(manga: SManga) =
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
override fun chapterListParse(response: Response): List<SChapter> {
val data = decodeBytes<Entry>(response.body)
return listOf(
SChapter.create().apply {
url = "/g/${data.id}/${data.key}"
name = "Chapter"
date_upload = data.publishedAt * 1000
chapter_number = 1f
},
)
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${getPathFromUrl(chapter.url)}"
// Page List
override fun pageListRequest(chapter: SChapter) =
GET("$libraryUrl/${getPathFromUrl(chapter.url)}", headers)
override fun pageListParse(response: Response): List<Page> {
val data = decodeBytes<Entry>(response.body)
val imageData = getImageData(data)
return data.data.mapIndexed { i, img ->
Page(
i,
imageUrl = "$cdnUrl/${data.id}/${imageData.key}/${imageData.hash}/b/${img.name}",
)
}
}
private fun getImageData(entry: Entry): ImageData {
val keys = entryExtraData.find { it.id == entry.id }
if (keys != null) {
return ImageData(keys.id, keys.key, keys.hash)
}
try {
val response =
client.newCall(GET("$libraryUrl/${entry.id}/${entry.key}/data", headers)).execute()
val body = response.body
return decodeBytes(body)
} catch (_: IOException) {
throw IOException("Complete a Captcha in the site to continue")
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!.replace("/b/", "/${preferences.imageQuality}/"), headers)
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
// Settings
@SuppressLint("SetTextI18n")
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val imageQualityPref = ListPreference(screen.context).apply {
key = IMAGE_QUALITY_PREF
title = "Image quality"
entries = arrayOf("Original", "Resampled")
entryValues = arrayOf("a", "b")
setDefaultValue("b")
summary = "%s"
setEnabled(false)
}
val openSourcePref = SwitchPreferenceCompat(screen.context).apply {
key = OPEN_SOURCE_PREF
title = "Open in FAKKU in WebView"
summary =
"Enable to open the search the book in FAKKU when opening the manga or chapter in WebView. If only one result exists, it will open that one."
setDefaultValue(false)
}
screen.addPreference(imageQualityPref)
screen.addPreference(openSourcePref)
}
override fun getFilterList() = FilterList(
CategoryGroup(),
SortFilter(),
FavoritesFilter(),
)
private class CategoryFilter(name: String) : Filter.CheckBox(name, false)
private class FavoritesFilter : Filter.CheckBox(
"Show only my favorites",
)
private class CategoryGroup : Filter.Group<CategoryFilter>(
"Categories",
listOf("Manga", "Doujinshi", "Illustration").map { CategoryFilter(it) },
)
private class SortFilter : Filter.Sort(
"Sort",
arrayOf("Title", "Pages", "Date published", "Date uploaded", "Popularity"),
Selection(2, false),
)
private val SharedPreferences.imageQuality
get() = getString(IMAGE_QUALITY_PREF, "b")!!
private val SharedPreferences.openSource
get() = getBoolean(OPEN_SOURCE_PREF, false)
private fun apiInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val requestUrl = request.url.toString()
return if (requestUrl.contains("/api/v1")) {
val newRequestBuilder = request.newBuilder()
if (requestUrl.contains(Regex("/\\d+/\\S+"))) {
newRequestBuilder.header(
"Referer",
requestUrl.replace(libraryUrl, "$baseUrl/g"),
)
} else if (requestUrl.contains("user/favorites")) {
newRequestBuilder.header(
"Referer",
requestUrl.replace("$apiUrl/user/favorites", "$baseUrl/favorites"),
)
} else {
newRequestBuilder.header("Referer", requestUrl.replace(libraryUrl, baseUrl))
}
chain.proceed(newRequestBuilder.build())
} else {
chain.proceed(request)
}
}
private fun isLoggedIn() = client.cookieJar.loadForRequest(baseUrl.toHttpUrl()).any {
it.name == "session"
}
private val entryExtraData by lazy {
client.newCall(GET(KEYS_JSON, headers)).execute()
.use { json.decodeFromStream<List<ExtraData>>(it.body.byteStream()) }
}
companion object {
private const val IMAGE_QUALITY_PREF = "image_quality"
private const val OPEN_SOURCE_PREF = "use_manga_source"
private const val KEYS_JSON =
"https://gist.githubusercontent.com/LetrixZ/2b559cc5829d1c221c701e02ecd81411/raw/keys.json"
}
}

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.extension.en.anchira
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ListEntry(
val id: Int,
val key: String,
val title: String,
val cover: Image,
val tags: List<Tag> = emptyList(),
)
@Serializable
data class Image(
@SerialName("n") val name: String,
)
@Serializable
data class Tag(
var name: String,
var namespace: Int? = null,
)
@Serializable
data class LibraryResponse(
val entries: List<ListEntry> = emptyList(),
val total: Int,
val page: Int,
val limit: Int,
)
@Serializable
data class Entry(
val id: Int,
val key: String,
@SerialName("published_at") val publishedAt: Long,
val title: String,
@SerialName("thumb_index") val thumbnailIndex: Int,
val data: List<Image>,
val tags: List<Tag> = emptyList(),
val url: String? = null,
)
@Serializable
data class ImageData(
val id: Int,
val key: String,
val hash: String,
)
@Serializable
data class ExtraData(
val id: Int,
val key: String,
val hash: String,
val url: String?,
)

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.en.anchira
import android.util.Base64
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.ResponseBody
import okio.ByteString.Companion.decodeBase64
object AnchiraHelper {
const val KEY = "ZnVjayBuaWdnZXJzIGFuZCBmYWdnb3RzLCBhbmQgZGVhdGggdG8gYWxsIGpld3M="
val json = Json { ignoreUnknownKeys = true }
fun getPathFromUrl(url: String) = "${url.split("/").reversed()[1]}/${url.split("/").last()}"
inline fun <reified T> decodeBytes(body: ResponseBody): T {
val encryptedText = body.string().decodeBase64()!!
return json.decodeFromString(
XXTEA.decryptToString(
encryptedText.toByteArray(),
key = Base64.decode(KEY, Base64.DEFAULT).decodeToString(),
)!!,
)
}
fun prepareTags(tags: List<Tag>) = tags.map {
if (it.namespace == null) {
it.namespace = 6
}
it
}.sortedBy { it.namespace }.map {
return@map it.name.lowercase()
}.joinToString(", ") { it }
}

View File

@ -0,0 +1,107 @@
package eu.kanade.tachiyomi.extension.en.anchira
object XXTEA {
private const val DELTA = -0x61c88647
@Suppress("NOTHING_TO_INLINE", "FunctionName")
private inline fun MX(sum: Int, y: Int, z: Int, p: Int, e: Int, k: IntArray): Int {
return (z.ushr(5) xor (y shl 2)) + (y.ushr(3) xor (z shl 4)) xor (sum xor y) + (k[p and 3 xor e] xor z)
}
private fun decrypt(data: ByteArray, key: ByteArray): ByteArray =
data.takeIf { it.isNotEmpty() }
?.let {
decrypt(data.toIntArray(false), key.fixKey().toIntArray(false))
.toByteArray(true)
} ?: data
fun decrypt(data: ByteArray, key: String): ByteArray? =
kotlin.runCatching { decrypt(data, key.toByteArray(Charsets.UTF_8)) }.getOrNull()
fun decryptToString(data: ByteArray, key: String): String? =
kotlin.runCatching { decrypt(data, key)?.toString(Charsets.UTF_8) }.getOrNull()
private fun decrypt(v: IntArray, k: IntArray): IntArray {
val n = v.size - 1
if (n < 1) {
return v
}
var p: Int
val q = 6 + 52 / (n + 1)
var z: Int
var y = v[0]
var sum = q * DELTA
var e: Int
while (sum != 0) {
e = sum.ushr(2) and 3
p = n
while (p > 0) {
z = v[p - 1]
v[p] -= MX(sum, y, z, p, e, k)
y = v[p]
p--
}
z = v[n]
v[0] -= MX(sum, y, z, p, e, k)
y = v[0]
sum -= DELTA
}
return v
}
private fun ByteArray.fixKey(): ByteArray {
if (size == 16) return this
val fixedKey = ByteArray(16)
if (size < 16) {
copyInto(fixedKey)
} else {
copyInto(fixedKey, endIndex = 16)
}
return fixedKey
}
private fun ByteArray.toIntArray(includeLength: Boolean): IntArray {
var n = if (size and 3 == 0) {
size.ushr(2)
} else {
size.ushr(2) + 1
}
val result: IntArray
if (includeLength) {
result = IntArray(n + 1)
result[n] = size
} else {
result = IntArray(n)
}
n = size
for (i in 0 until n) {
result[i.ushr(2)] =
result[i.ushr(2)] or (0x000000ff and this[i].toInt() shl (i and 3 shl 3))
}
return result
}
private fun IntArray.toByteArray(includeLength: Boolean): ByteArray? {
var n = size shl 2
if (includeLength) {
val m = this[size - 1]
n -= 4
if (m < n - 3 || m > n) {
return null
}
n = m
}
val result = ByteArray(n)
for (i in 0 until n) {
result[i] = this[i.ushr(2)].ushr(i and 3 shl 3).toByte()
}
return result
}
}