diff --git a/src/all/unionmangas/AndroidManifest.xml b/src/all/unionmangas/AndroidManifest.xml new file mode 100644 index 000000000..fe3c9ed17 --- /dev/null +++ b/src/all/unionmangas/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/unionmangas/build.gradle b/src/all/unionmangas/build.gradle new file mode 100644 index 000000000..1d8bfddf1 --- /dev/null +++ b/src/all/unionmangas/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'Union Mangas' + extClass = '.UnionMangasFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(':lib:cryptoaes')) +} diff --git a/src/all/unionmangas/res/mipmap-hdpi/ic_launcher.png b/src/all/unionmangas/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..869d060df Binary files /dev/null and b/src/all/unionmangas/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/unionmangas/res/mipmap-mdpi/ic_launcher.png b/src/all/unionmangas/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..03dacb35d Binary files /dev/null and b/src/all/unionmangas/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/unionmangas/res/mipmap-xhdpi/ic_launcher.png b/src/all/unionmangas/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f87d63d82 Binary files /dev/null and b/src/all/unionmangas/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/unionmangas/res/mipmap-xxhdpi/ic_launcher.png b/src/all/unionmangas/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..10b4edef9 Binary files /dev/null and b/src/all/unionmangas/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/unionmangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/unionmangas/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..aa1de0bf5 Binary files /dev/null and b/src/all/unionmangas/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangas.kt b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangas.kt new file mode 100644 index 000000000..0572036a2 --- /dev/null +++ b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangas.kt @@ -0,0 +1,238 @@ +package eu.kanade.tachiyomi.extension.all.unionmangas + +import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +class UnionMangas(private val langOption: LanguageOption) : HttpSource() { + override val lang = langOption.lang + + override val name: String = "Union Mangas" + + override val baseUrl: String = "https://unionmangas.xyz" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + val langApiInfix = when (lang) { + "it" -> langOption.infix + else -> "v3/po" + } + + override val client = network.client.newBuilder() + .rateLimit(5, 2, TimeUnit.SECONDS) + .build() + + private fun apiHeaders(url: String): Headers { + val date = apiDateFormat.format(Date()) + val path = url.toUrlWithoutDomain() + + return headersBuilder() + .add("_hash", authorization(apiSeed, domain, date)) + .add("_tranId", authorization(apiSeed, domain, date, path)) + .add("_date", date) + .add("_domain", domain) + .add("_path", path) + .add("Origin", baseUrl) + .add("Host", apiUrl.removeProtocol()) + .add("Referer", "$baseUrl/") + .build() + } + + private fun authorization(vararg payloads: String): String { + val md = MessageDigest.getInstance("MD5") + val bytes = payloads.joinToString("").toByteArray() + val digest = md.digest(bytes) + return digest + .fold("") { str, byte -> str + "%02x".format(byte) } + .padStart(32, '0') + } + + override fun chapterListParse(response: Response) = throw UnsupportedOperationException() + + override fun fetchChapterList(manga: SManga): Observable> { + val chapters = mutableListOf() + var currentPage = 0 + do { + val chaptersDto = fetchChapterListPageable(manga, currentPage) + chapters += chaptersDto.toSChapter(langOption) + currentPage++ + } while (chaptersDto.hasNextPage()) + return Observable.just(chapters.reversed()) + } + + private fun fetchChapterListPageable(manga: SManga, page: Int): ChapterPageDto { + val maxResult = 16 + val url = "$apiUrl/api/$langApiInfix/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/ASC" + return client.newCall(GET(url, apiHeaders(url))).execute() + .parseAs() + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val nextData = response.parseNextData() + val dto = nextData.data.latestUpdateDto + val mangas = dto.mangas.map { mangaParse(it, nextData.query) } + + return MangasPage( + mangas = mangas, + hasNextPage = dto.hasNextPage(), + ) + } + + override fun latestUpdatesRequest(page: Int): Request { + val url = "$baseUrl/${langOption.infix}/latest-releases".toHttpUrl().newBuilder() + .addQueryParameter("page", "$page") + .build() + return GET(url, headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val nextData = response.parseNextData() + val dto = nextData.data.mangaDetailsDto + return SManga.create().apply { + title = dto.title + genre = dto.genres + thumbnail_url = dto.thumbnailUrl + url = mangaUrlParse(dto.slug, nextData.query.type) + status = dto.status + } + } + + override fun pageListParse(response: Response): List { + val chaptersDto = decryptChapters(response) + return chaptersDto.images.mapIndexed { index, imageUrl -> + Page(index, imageUrl = imageUrl) + } + } + + private fun decryptChapters(response: Response): ChaptersDto { + val document = response.asJsoup() + val password = findChapterPassword(document) + val pageListData = document.parseNextData().data.pageListData + val decodedData = CryptoAES.decrypt(pageListData, password) + return ChaptersDto( + data = json.decodeFromString(decodedData).data, + delimiter = langOption.pageDelimiter, + ) + } + + private fun findChapterPassword(document: Document): String { + val regxPasswordUrl = """\/pages\/%5Btype%5D\/%5Bidmanga%5D\/%5Biddetail%5D-.+\.js""".toRegex() + val regxFindPassword = """AES\.decrypt\(\w+,"(?[^"]+)"\)""".toRegex(RegexOption.MULTILINE) + val jsDecryptUrl = document.select("script") + .map { it.absUrl("src") } + .first { regxPasswordUrl.find(it) != null } + val jsDecrypt = client.newCall(GET(jsDecryptUrl, headers)).execute().asJsoup().html() + return regxFindPassword.find(jsDecrypt)?.groups?.get("password")!!.value.trim() + } + + override fun popularMangaParse(response: Response): MangasPage { + val dto = response.parseNextData() + val mangas = dto.data.mangas.map { it.details }.map { mangaParse(it, dto.query) } + return MangasPage( + mangas = mangas, + hasNextPage = false, + ) + } + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/${langOption.infix}") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val maxResult = 6 + val url = "$apiUrl/api/$langApiInfix/searchforms/$maxResult/".toHttpUrl().newBuilder() + .addPathSegment(query) + .addPathSegment("${page - 1}") + .build() + return GET(url, apiHeaders(url.toString())) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(slugPrefix)) { + val mangaUrl = query.substringAfter(slugPrefix) + return client.newCall(GET("$baseUrl/${langOption.infix}/$mangaUrl", headers)) + .asObservableSuccess().map { response -> + val manga = mangaDetailsParse(response).apply { + url = mangaUrl + } + MangasPage(listOf(manga), false) + } + } + return super.fetchSearchManga(page, query, filters) + } + + override fun imageUrlParse(response: Response): String = "" + + override fun searchMangaParse(response: Response): MangasPage { + val mangasDto = response.parseAs().apply { + currentPage = response.request.url.pathSegments.last() + } + + return MangasPage( + mangas = mangasDto.toSManga(langOption.infix), + hasNextPage = mangasDto.hasNextPage(), + ) + } + + private inline fun Response.parseNextData() = asJsoup().parseNextData() + + private inline fun Document.parseNextData(): NextData { + val jsonContent = selectFirst("script#__NEXT_DATA__")!!.html() + return json.decodeFromString>(jsonContent) + } + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + private fun String.removeProtocol() = trim().replace("https://", "") + + private fun SManga.slug() = this.url.split("/").last() + + private fun String.toUrlWithoutDomain() = trim().replace(apiUrl, "") + + private fun mangaParse(dto: MangaDto, query: QueryDto): SManga { + return SManga.create().apply { + title = dto.title + thumbnail_url = dto.thumbnailUrl + status = dto.status + url = mangaUrlParse(dto.slug, query.type) + genre = dto.genres + } + } + + private fun mangaUrlParse(slug: String, pathSegment: String) = "/$pathSegment/$slug" + + companion object { + val apiUrl = "https://api.unionmanga.xyz" + val apiSeed = "8e0550790c94d6abc71d738959a88d209690dc86" + val domain = "yaoi-chan.xyz" + val slugPrefix = "slug:" + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + val apiDateFormat = SimpleDateFormat("EE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH) + .apply { timeZone = TimeZone.getTimeZone("GMT") } + } +} diff --git a/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasDto.kt b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasDto.kt new file mode 100644 index 000000000..05cce5d88 --- /dev/null +++ b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasDto.kt @@ -0,0 +1,149 @@ +package eu.kanade.tachiyomi.extension.all.unionmangas + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class NextData(val props: Props, val query: QueryDto) { + val data get() = props.pageProps +} + +@Serializable +class Props(val pageProps: T) + +@Serializable +class PopularMangaProps(@SerialName("data_popular") val mangas: List) + +@Serializable +class LatestUpdateProps(@SerialName("data_lastuppdate") val latestUpdateDto: MangaListDto) + +@Serializable +class MangaDetailsProps(@SerialName("dataManga") val mangaDetailsDto: MangaDetailsDto) + +@Serializable +class ChaptersProps(@SerialName("data") val pageListData: String) + +@Serializable +abstract class Pageable { + abstract var currentPage: String? + abstract var totalPage: Int + + fun hasNextPage() = + try { (currentPage!!.toInt() + 1) < totalPage } catch (_: Exception) { false } +} + +@Serializable +class ChapterPageDto( + val totalRecode: Int = 0, + override var currentPage: String?, + override var totalPage: Int, + @SerialName("data") val chapters: List = emptyList(), +) : Pageable() { + fun toSChapter(langOption: LanguageOption): List = + chapters.map { chapter -> + SChapter.create().apply { + name = chapter.name + date_upload = chapter.date.toDate() + url = "/${langOption.infix}${chapter.toChapterUrl(langOption.chpPrefix)}" + } + } + + private fun String.toDate(): Long = + try { UnionMangas.dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L } + + private fun ChapterDto.toChapterUrl(prefix: String) = "/${this.slugManga}/$prefix-${this.id}" +} + +@Serializable +class ChapterDto( + val date: String, + val slug: String, + @SerialName("idDoc") val slugManga: String, + @SerialName("idDetail") val id: String, + @SerialName("nameChapter") val name: String, +) + +@Serializable +class QueryDto( + val type: String, +) + +@Serializable +class MangaListDto( + override var currentPage: String?, + override var totalPage: Int, + @SerialName("data") val mangas: List, +) : Pageable() { + fun toSManga(siteLang: String) = mangas.map { dto -> + SManga.create().apply { + title = dto.title + thumbnail_url = dto.thumbnailUrl + status = dto.status + url = mangaUrlParse(dto.slug, siteLang) + genre = dto.genres + } + } +} + +@Serializable +class PopularMangaDto( + @SerialName("document") val details: MangaDto, +) + +@Serializable +class MangaDto( + @SerialName("name") val title: String, + @SerialName("image") private val _thumbnailUrl: String, + @SerialName("idDoc") val slug: String, + @SerialName("genres") private val _genres: String, + @SerialName("status") val _status: String, +) { + val thumbnailUrl get() = "${UnionMangas.apiUrl}$_thumbnailUrl" + val genres get() = _genres.split(",").joinToString { it.trim() } + val status get() = toSMangaStatus(_status) +} + +@Serializable +class MangaDetailsDto( + @SerialName("name") val title: String, + @SerialName("image") private val _thumbnailUrl: String, + @SerialName("idDoc") val slug: String, + @SerialName("lsgenres") private val _genres: List, + @SerialName("lsstatus") private val _status: List, +) { + + val thumbnailUrl get() = "${UnionMangas.apiUrl}$_thumbnailUrl" + val genres get() = _genres.joinToString { it.name } + val status get() = toSMangaStatus(_status.first().name) + + @Serializable + class Prop( + val name: String, + ) +} + +@Serializable +class ChaptersDto( + @SerialName("dataManga") val data: PageDto, + private var delimiter: String = "", +) { + val images get() = data.getImages(delimiter) +} + +@Serializable +class PageDto( + @SerialName("source") private val imgData: String, +) { + fun getImages(delimiter: String): List = imgData.split(delimiter) +} + +private fun mangaUrlParse(slug: String, pathSegment: String) = "/$pathSegment/$slug" + +private fun toSMangaStatus(status: String) = + when (status.lowercase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } diff --git a/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasFactory.kt b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasFactory.kt new file mode 100644 index 000000000..0356f440f --- /dev/null +++ b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasFactory.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.unionmangas + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class UnionMangasFactory : SourceFactory { + override fun createSources(): List = languages.map { UnionMangas(it) } +} + +class LanguageOption(val lang: String, val infix: String = lang, val chpPrefix: String, val pageDelimiter: String) + +val languages = listOf( + LanguageOption("it", "italy", "leer", ","), + LanguageOption("pt-BR", "manga-br", "cap", "#"), +) diff --git a/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasUrlActivity.kt b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasUrlActivity.kt new file mode 100644 index 000000000..c4c8fb9e7 --- /dev/null +++ b/src/all/unionmangas/src/eu/kanade/tachiyomi/extension/all/unionmangas/UnionMangasUrlActivity.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.all.unionmangas + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class UnionMangasUrlActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val host = intent?.data?.host + val pathSegments = intent?.data?.pathSegments + + if (host != null && pathSegments != null) { + val intent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", slug(pathSegments)) + putExtra("filter", packageName) + } + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("UnionMangasUrlActivity", e.toString()) + } + } + + finish() + exitProcess(0) + } + + private fun slug(pathSegments: List) = "${UnionMangas.slugPrefix}${pathSegments.last()}" +}