Add Azuki (#10543)
* Add Azuki * nsfw * lint * improvements * rm null * simplify
This commit is contained in:
parent
a45d427b92
commit
84da2bf59f
8
src/en/azuki/build.gradle
Normal file
8
src/en/azuki/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'Azuki'
|
||||
extClass = '.Azuki'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/azuki/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/en/azuki/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.5 KiB |
BIN
src/en/azuki/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/en/azuki/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
BIN
src/en/azuki/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/en/azuki/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
src/en/azuki/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/en/azuki/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
src/en/azuki/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/en/azuki/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
220
src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Azuki.kt
Normal file
220
src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Azuki.kt
Normal file
@ -0,0 +1,220 @@
|
||||
package eu.kanade.tachiyomi.extension.en.azuki
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
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 keiyoushi.utils.tryParse
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class Azuki : HttpSource() {
|
||||
|
||||
override val name = "Azuki"
|
||||
override val baseUrl = "https://www.azuki.co"
|
||||
override val lang = "en"
|
||||
override val supportsLatest = true
|
||||
|
||||
private val apiUrl = "https://production.api.azuki.co"
|
||||
private val organizationKey = "199e5a19-a236-49f5-81f4-43d4a541748a"
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
|
||||
override val client: OkHttpClient = network.client.newBuilder()
|
||||
.addInterceptor(ImageInterceptor())
|
||||
.rateLimitHost(apiUrl.toHttpUrl(), 1)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/discover?sort=popular&page=$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("ol.o-series-card-list li").map(::mangaFromElement)
|
||||
val hasNextPage = document.selectFirst("a[rel=next]") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/discover?sort=recent_series&page=$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/discover".toHttpUrl().newBuilder()
|
||||
url.addQueryParameter("q", query)
|
||||
url.addQueryParameter("page", page.toString())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SortFilter -> url.addQueryParameter("sort", filter.toUriPart())
|
||||
is AccessTypeFilter -> filter.toUriPart().takeIf { it.isNotEmpty() }?.let { url.addQueryParameter("access_type", it) }
|
||||
is GenreFilter ->
|
||||
filter.state
|
||||
.filter { it.state }
|
||||
.forEach { url.addQueryParameter("tags[]", it.value) }
|
||||
is PublisherFilter -> filter.toUriPart().takeIf { it.isNotEmpty() }?.let { url.addQueryParameter("publisher_slug", it) }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Details
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
return SManga.create().apply {
|
||||
title = document.selectFirst("h1")!!.text()
|
||||
author = document.selectFirst(".o-series-summary__byline p")?.text()?.substringAfter("By ")?.substringBefore(" Published by")
|
||||
artist = author
|
||||
description = document.selectFirst(".o-series-summary__description")?.text()
|
||||
genre = document.select(".o-series-summary__genres a").joinToString { it.text() }
|
||||
thumbnail_url = document.selectFirst(".o-series-summary__cover img")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val seriesSlug = response.request.url.pathSegments.lastOrNull() ?: return emptyList()
|
||||
val mangaUuid = document.selectFirst("azuki-chapter-row-list[series-uuid]")?.attr("series-uuid")
|
||||
|
||||
val unlockedChapterIds = if (mangaUuid != null) {
|
||||
try {
|
||||
val apiResponse = client.newCall(GET("$apiUrl/user/mangas/$mangaUuid/v0", apiHeaders())).execute()
|
||||
if (apiResponse.isSuccessful) {
|
||||
val result = apiResponse.parseAs<UserMangaStatusDto>()
|
||||
(result.purchasedChapterUuids + result.unlockedChapterUuids).toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
return document.select(".m-chapter-row-list .m-chapter-row").mapNotNull { chapterRow ->
|
||||
val link = chapterRow.selectFirst("a.a-card-link") ?: return@mapNotNull null
|
||||
val href = link.absUrl("href").toHttpUrl()
|
||||
|
||||
val chapterId = if ("/checkout/" in href.encodedPath) {
|
||||
href.queryParameter("chapter_uuids[]")
|
||||
} else {
|
||||
href.pathSegments.lastOrNull()
|
||||
}
|
||||
|
||||
if (chapterId.isNullOrEmpty()) return@mapNotNull null
|
||||
|
||||
SChapter.create().apply {
|
||||
url = "/series/$seriesSlug/read/$chapterId"
|
||||
name = link.selectFirst(".m-chapter-row__title-cluster span")?.text() ?: link.text()
|
||||
date_upload = dateFormat.tryParse(chapterRow.selectFirst(".m-chapter-row__date time")?.attr("datetime"))
|
||||
|
||||
val isPremium = chapterRow.selectFirst(".m-chapter-row__premium-badge") != null ||
|
||||
chapterRow.parent()?.hasClass("m-chapter-card--secondary") == true
|
||||
|
||||
if (isPremium && chapterId !in unlockedChapterIds) {
|
||||
name = "🔒 $name"
|
||||
}
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val chapterId = chapter.url.substringAfter("/read/")
|
||||
val apiUrl = "$apiUrl/chapters/$chapterId/pages/v1"
|
||||
return GET(apiUrl, apiHeaders())
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
throw Exception("This chapter is locked. Log in via WebView and unlock the chapter to read.")
|
||||
}
|
||||
throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
pageListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<PageListDto>()
|
||||
return result.data.pages.mapIndexed { i, page ->
|
||||
val imageList = page.image.webp ?: page.image.jpg
|
||||
?: throw Exception("No images found for page ${i + 1}")
|
||||
|
||||
val bestAvailableUrl = imageList.maxByOrNull { it.width }?.url
|
||||
?: throw Exception("No image URL found for page ${i + 1}")
|
||||
|
||||
val resolutionRegex = Regex("""/(\d+)\.(webp|jpg)$""")
|
||||
val highResUrl = resolutionRegex.replace(bestAvailableUrl, "/2000.$2")
|
||||
|
||||
Page(i, imageUrl = "$highResUrl?drm=1")
|
||||
}
|
||||
}
|
||||
|
||||
private fun apiHeaders(): Headers {
|
||||
val token = client.cookieJar.loadForRequest(baseUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "idToken" }?.value
|
||||
|
||||
return headersBuilder()
|
||||
.set("azuki-organization-key", organizationKey)
|
||||
.apply {
|
||||
if (token != null) {
|
||||
set("x-user-token", token)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
// Filters
|
||||
override fun getFilterList(): FilterList {
|
||||
return FilterList(
|
||||
SortFilter(),
|
||||
AccessTypeFilter(),
|
||||
PublisherFilter(),
|
||||
GenreFilter(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
val link = element.selectFirst("a.a-card-link")!!
|
||||
setUrlWithoutDomain(link.attr("href"))
|
||||
title = link.text()
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package eu.kanade.tachiyomi.extension.en.azuki
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class PageListDto(
|
||||
val data: PageDataDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PageDataDto(
|
||||
val pages: List<PageDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val image: ImageDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ImageDto(
|
||||
val webp: List<ImageUrlDto>?,
|
||||
val jpg: List<ImageUrlDto>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ImageUrlDto(
|
||||
val url: String,
|
||||
val width: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UserMangaStatusDto(
|
||||
@SerialName("purchased_chapter_uuids")
|
||||
val purchasedChapterUuids: List<String> = emptyList(),
|
||||
@SerialName("unlocked_chapter_uuids")
|
||||
val unlockedChapterUuids: List<String> = emptyList(),
|
||||
)
|
@ -0,0 +1,87 @@
|
||||
package eu.kanade.tachiyomi.extension.en.azuki
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
class CheckBox(name: String, val value: String) : Filter.CheckBox(name)
|
||||
|
||||
class SortFilter : UriPartFilter(
|
||||
"Sort by",
|
||||
arrayOf(
|
||||
Pair("Popular", "popular"),
|
||||
Pair("Recent Series", "recent_series"),
|
||||
Pair("Alphabetical", "alphabetical"),
|
||||
),
|
||||
)
|
||||
|
||||
class AccessTypeFilter : UriPartFilter(
|
||||
"Access Type",
|
||||
arrayOf(
|
||||
Pair("Any", ""),
|
||||
Pair("Premium", "fully_premium"),
|
||||
Pair("Ebook", "purchasable"),
|
||||
),
|
||||
)
|
||||
|
||||
class PublisherFilter : UriPartFilter(
|
||||
"Publisher",
|
||||
arrayOf(
|
||||
Pair("Any", ""),
|
||||
Pair("ABLAZE", "ablaze"),
|
||||
Pair("Azuki", "azuki"),
|
||||
Pair("CLLENN", "cllenn"),
|
||||
Pair("Coamix Inc.", "coamix"),
|
||||
Pair("CORK", "cork"),
|
||||
Pair("Futabasha Publishers Ltd.", "futabasha-publishers-ltd"),
|
||||
Pair("Glacier Bay Books", "glacier-bay-books"),
|
||||
Pair("J-Novel Club", "j-novel-club"),
|
||||
Pair("KADOKAWA", "kadokawa"),
|
||||
Pair("Kaiten Books", "kaiten-books"),
|
||||
Pair("Kodansha", "kodansha"),
|
||||
Pair("Manga Mavericks Books", "manga-mavericks-books"),
|
||||
Pair("Manga Up!", "manga-up"),
|
||||
Pair("One Peace Books", "one-peace-books"),
|
||||
Pair("SOZO Comics", "sozo-comics"),
|
||||
Pair("Star Fruit Books", "star-fruit-books"),
|
||||
Pair("Toii Games (MediBang!)", "toii-games-medibang"),
|
||||
Pair("TORICO (MediBang!)", "torico-medibang"),
|
||||
Pair("Unknown", "unknown"),
|
||||
Pair("VAST Visual", "vast-visual"),
|
||||
Pair("YUZU Comics", "yuzu-comics"),
|
||||
),
|
||||
)
|
||||
|
||||
class GenreFilter : Filter.Group<CheckBox>(
|
||||
"Genres",
|
||||
listOf(
|
||||
CheckBox("Action", "action"),
|
||||
CheckBox("Adventure", "adventure"),
|
||||
CheckBox("Comedy", "comedy"),
|
||||
CheckBox("Drama", "drama"),
|
||||
CheckBox("Ecchi", "ecchi"),
|
||||
CheckBox("Fantasy", "fantasy"),
|
||||
CheckBox("Harem", "harem"),
|
||||
CheckBox("Historical", "historical"),
|
||||
CheckBox("Horror", "horror"),
|
||||
CheckBox("Josei", "josei"),
|
||||
CheckBox("Martial Arts", "martial-arts"),
|
||||
CheckBox("Mature", "mature"),
|
||||
CheckBox("Mecha", "mecha"),
|
||||
CheckBox("Mystery", "mystery"),
|
||||
CheckBox("Psychological", "psychological"),
|
||||
CheckBox("Romance", "romance"),
|
||||
CheckBox("School Life", "school-life"),
|
||||
CheckBox("Sci-Fi", "scifi"),
|
||||
CheckBox("Seinen", "seinen"),
|
||||
CheckBox("Shojo", "shoujo"),
|
||||
CheckBox("Shonen", "shounen"),
|
||||
CheckBox("Slice of Life", "slice-of-life"),
|
||||
CheckBox("Sports", "sports"),
|
||||
CheckBox("Supernatural", "supernatural"),
|
||||
CheckBox("Tragedy", "tragedy"),
|
||||
),
|
||||
)
|
@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.extension.en.azuki
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class ImageInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (!request.url.queryParameterNames.contains("drm")) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val encryptedBytes = response.body.bytes()
|
||||
val decryptedBytes = decryptImage(encryptedBytes)
|
||||
val decryptedBody = decryptedBytes.toResponseBody(response.body.contentType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(decryptedBody)
|
||||
.build()
|
||||
}
|
||||
|
||||
// https://www.azuki.co/assets/js/DecryptedImage.57631a1f.js
|
||||
private fun decryptImage(encryptedData: ByteArray): ByteArray {
|
||||
val keyByte = 174
|
||||
return encryptedData.map { (it.toInt() xor keyByte).toByte() }.toByteArray()
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user