Union Manga: bugfix (#3391)

* Fix popularManga and detailsManga

* Cleanup

* Fix searchManga and chapter pages

* Rename and move mangaDeatilsDto

* Fix lint

* Fix available languages

* Fix latestUpdate

* Fix russian pages

* Fix getMangaUrl

* Update icons

* Remove try/catch unneeded

* Bump versionId

* Add compatibility with previous version

* Cleanup

* Refactoring getMangaUrl
This commit is contained in:
Chopper 2024-06-05 09:18:40 -03:00 committed by Draff
parent c26fee2dcd
commit d1e9584966
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
9 changed files with 144 additions and 251 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Union Mangas' extName = 'Union Mangas'
extClass = '.UnionMangasFactory' extClass = '.UnionMangasFactory'
extVersionCode = 3 extVersionCode = 4
isNsfw = true isNsfw = true
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.extension.all.unionmangas 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.GET
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
@ -10,22 +9,16 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class UnionMangas(private val langOption: LanguageOption) : HttpSource() { class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
override val lang = langOption.lang override val lang = langOption.lang
@ -38,39 +31,12 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
private val json: Json by injectLazy() private val json: Json by injectLazy()
val langApiInfix = when (lang) {
"it" -> langOption.infix
else -> "v3/po"
}
override val client = network.client.newBuilder() override val client = network.client.newBuilder()
.rateLimit(5, 2, TimeUnit.SECONDS) .rateLimit(2)
.build() .build()
private fun apiHeaders(url: String): Headers { override fun headersBuilder(): Headers.Builder = super.headersBuilder()
val date = apiDateFormat.format(Date()) .set("Referer", "$baseUrl/")
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 chapterListParse(response: Response) = throw UnsupportedOperationException()
@ -79,95 +45,101 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
var currentPage = 0 var currentPage = 0
do { do {
val chaptersDto = fetchChapterListPageable(manga, currentPage) val chaptersDto = fetchChapterListPageable(manga, currentPage)
chapters += chaptersDto.toSChapter(langOption) chapters += chaptersDto.data.map { chapter ->
SChapter.create().apply {
name = chapter.name
date_upload = chapter.date.toDate()
url = chapter.toChapterUrl(langOption.infix)
}
}
currentPage++ currentPage++
} while (chaptersDto.hasNextPage()) } while (chaptersDto.hasNextPage())
return Observable.just(chapters.reversed()) return Observable.just(chapters)
} }
private fun fetchChapterListPageable(manga: SManga, page: Int): ChapterPageDto { private fun fetchChapterListPageable(manga: SManga, page: Int): Pageable<ChapterDto> {
manga.apply {
url = getURLCompatibility(url)
}
val maxResult = 16 val maxResult = 16
val url = "$apiUrl/api/$langApiInfix/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/ASC" val url = "$apiUrl/${langOption.infix}/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/ASC"
return client.newCall(GET(url, apiHeaders(url))).execute() return client.newCall(GET(url, headers)).execute()
.parseAs<ChapterPageDto>() .parseAs<Pageable<ChapterDto>>()
} }
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
val nextData = response.parseNextData<LatestUpdateProps>()
val dto = nextData.data.latestUpdateDto
val mangas = dto.mangas.map { mangaParse(it, nextData.query) }
override fun latestUpdatesRequest(page: Int): Request {
val maxResult = 24
val url = "$apiUrl/${langOption.infix}/HomeLastUpdate".toHttpUrl().newBuilder()
.addPathSegment("$maxResult")
.addPathSegment("${page - 1}")
.build()
return GET(url, headers)
}
override fun getMangaUrl(manga: SManga): String {
manga.apply {
url = getURLCompatibility(url)
}
return baseUrl + manga.url.replace(langOption.infix, langOption.mangaSubstring)
}
override fun mangaDetailsRequest(manga: SManga): Request {
manga.apply {
url = getURLCompatibility(url)
}
val url = "$apiUrl/${langOption.infix}/getInfoManga".toHttpUrl().newBuilder()
.addPathSegment(manga.slug())
.build()
return GET(url, headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val dto = response.parseAs<MangaDetailsDto>()
return mangaParse(dto.details)
}
override fun pageListRequest(chapter: SChapter): Request {
val chapterSlug = getURLCompatibility(chapter.url)
.substringAfter(langOption.infix)
val url = "$apiUrl/${langOption.infix}/GetImageChapter$chapterSlug"
return GET(url, headers)
}
override fun pageListParse(response: Response): List<Page> {
val location = response.request.url.toString()
val dto = response.parseAs<PageDto>()
return dto.pages.mapIndexed { index, url ->
Page(index, location, imageUrl = url)
}
}
override fun popularMangaParse(response: Response): MangasPage {
val dto = response.parseAs<Pageable<MangaDto>>()
val mangas = dto.data.map(::mangaParse)
return MangasPage( return MangasPage(
mangas = mangas, mangas = mangas,
hasNextPage = dto.hasNextPage(), hasNextPage = dto.hasNextPage(),
) )
} }
override fun latestUpdatesRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val url = "$baseUrl/${langOption.infix}/latest-releases".toHttpUrl().newBuilder() val maxResult = 24
.addQueryParameter("page", "$page") return GET("$apiUrl/${langOption.infix}/HomeTopFllow/$maxResult/${page - 1}")
.build()
return GET(url, headers)
} }
override fun mangaDetailsParse(response: Response): SManga {
val nextData = response.parseNextData<MangaDetailsProps>()
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<Page> {
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<ChaptersProps>().data.pageListData
val decodedData = CryptoAES.decrypt(pageListData, password)
return ChaptersDto(
data = json.decodeFromString<ChaptersDto>(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+,"(?<password>[^"]+)"\)""".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<PopularMangaProps>()
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 { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val maxResult = 6 val maxResult = 20
val url = "$apiUrl/api/$langApiInfix/searchforms/$maxResult/".toHttpUrl().newBuilder() val url = "$apiUrl/${langOption.infix}/QuickSearch/".toHttpUrl().newBuilder()
.addPathSegment(query) .addPathSegment(query)
.addPathSegment("${page - 1}") .addPathSegment("$maxResult")
.build() .build()
return GET(url, apiHeaders(url.toString())) return GET(url, headers)
} }
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
@ -185,52 +157,54 @@ class UnionMangas(private val langOption: LanguageOption) : HttpSource() {
override fun imageUrlParse(response: Response): String = "" override fun imageUrlParse(response: Response): String = ""
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val mangasDto = response.parseAs<MangaListDto>().apply { val dto = response.parseAs<SearchDto>()
currentPage = response.request.url.pathSegments.last()
}
return MangasPage( return MangasPage(
mangas = mangasDto.toSManga(langOption.infix), dto.mangas.map(::mangaParse),
hasNextPage = mangasDto.hasNextPage(), false,
) )
} }
private inline fun <reified T> Response.parseNextData() = asJsoup().parseNextData<T>() /*
* Keeps compatibility with pt-BR previous version
* */
private fun getURLCompatibility(url: String): String {
val slugSuffix = "-br"
val mangaSubString = "manga-br"
private inline fun <reified T> Document.parseNextData(): NextData<T> { val oldSlug = url.substringAfter(mangaSubString)
val jsonContent = selectFirst("script#__NEXT_DATA__")!!.html() .substring(1)
return json.decodeFromString<NextData<T>>(jsonContent) .split("/")
.first()
val newSlug = oldSlug.substringBeforeLast(slugSuffix)
return url.replace(oldSlug, newSlug)
} }
private inline fun <reified T> Response.parseAs(): T { private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string()) return json.decodeFromString(body.string())
} }
private fun String.removeProtocol() = trim().replace("https://", "")
private fun SManga.slug() = this.url.split("/").last() private fun SManga.slug() = this.url.split("/").last()
private fun String.toUrlWithoutDomain() = trim().replace(apiUrl, "") private fun mangaParse(dto: MangaDto): SManga {
private fun mangaParse(dto: MangaDto, query: QueryDto): SManga {
return SManga.create().apply { return SManga.create().apply {
title = dto.title title = dto.title
thumbnail_url = dto.thumbnailUrl thumbnail_url = dto.thumbnailUrl
status = dto.status status = dto.status
url = mangaUrlParse(dto.slug, query.type) url = "/${langOption.infix}/${dto.slug}"
genre = dto.genres genre = dto.genres
initialized = true
} }
} }
private fun mangaUrlParse(slug: String, pathSegment: String) = "/$pathSegment/$slug" private fun String.toDate(): Long =
try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L }
companion object { companion object {
const val SEARCH_PREFIX = "slug:" const val SEARCH_PREFIX = "slug:"
val apiUrl = "https://api.unionmanga.xyz" val apiUrl = "https://app.unionmanga.xyz/api"
val apiSeed = "8e0550790c94d6abc71d738959a88d209690dc86" val oldApiUrl = "https://api.unionmanga.xyz"
val domain = "yaoi-chan.xyz" val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
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") }
} }
} }

View File

@ -1,149 +1,68 @@
package eu.kanade.tachiyomi.extension.all.unionmangas package eu.kanade.tachiyomi.extension.all.unionmangas
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
class NextData<T>(val props: Props<T>, val query: QueryDto) { class MangaDetailsDto(private val data: Props) {
val data get() = props.pageProps val details: MangaDto get() = data.details
@Serializable
class Props(
@SerialName("infoDoc") val details: MangaDto,
)
} }
@Serializable @Serializable
class Props<T>(val pageProps: T) open class Pageable<T>(
var currentPage: Int,
@Serializable var totalPage: Int,
class PopularMangaProps(@SerialName("data_popular") val mangas: List<PopularMangaDto>) val data: List<T>,
) {
@Serializable fun hasNextPage() = (currentPage + 1) <= totalPage
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<ChapterDto> = emptyList(),
) : Pageable() {
fun toSChapter(langOption: LanguageOption): List<SChapter> =
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 @Serializable
class ChapterDto( class ChapterDto(
val date: String, val date: String,
val slug: String,
@SerialName("idDoc") val slugManga: String, @SerialName("idDoc") val slugManga: String,
@SerialName("idDetail") val id: String, @SerialName("idDetail") val id: String,
@SerialName("nameChapter") val name: String, @SerialName("nameChapter") val name: String,
) ) {
fun toChapterUrl(lang: String) = "/$lang/${this.slugManga}/$id"
@Serializable
class QueryDto(
val type: String,
)
@Serializable
class MangaListDto(
override var currentPage: String?,
override var totalPage: Int,
@SerialName("data") val mangas: List<MangaDto>,
) : 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 @Serializable
class MangaDto( class MangaDto(
@SerialName("name") val title: String, @SerialName("name") val title: String,
@SerialName("image") private val _thumbnailUrl: String, @SerialName("image") private val _thumbnailUrl: String,
@SerialName("idDoc") val slug: String, @SerialName("idDoc") val slug: String,
@SerialName("genres") private val _genres: String, @SerialName("genresName") val genres: String,
@SerialName("status") val _status: String, @SerialName("status") val _status: String,
) { ) {
val thumbnailUrl get() = "${UnionMangas.apiUrl}$_thumbnailUrl" val thumbnailUrl get() = "${UnionMangas.oldApiUrl}$_thumbnailUrl"
val genres get() = _genres.split(",").joinToString { it.trim() }
val status get() = toSMangaStatus(_status)
}
@Serializable val status get() = when (_status) {
class MangaDetailsDto(
@SerialName("name") val title: String,
@SerialName("image") private val _thumbnailUrl: String,
@SerialName("idDoc") val slug: String,
@SerialName("lsgenres") private val _genres: List<Prop>,
@SerialName("lsstatus") private val _status: List<Prop>,
) {
val thumbnailUrl get() = "${UnionMangas.apiUrl}$_thumbnailUrl"
val genres get() = _genres.joinToString { it.name }
val status get() = toSMangaStatus(_status.firstOrNull()?.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<String> = imgData.split(delimiter)
}
private fun mangaUrlParse(slug: String, pathSegment: String) = "/$pathSegment/$slug"
private fun toSMangaStatus(status: String) =
when (status.lowercase()) {
"ongoing" -> SManga.ONGOING "ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED "completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN else -> SManga.UNKNOWN
} }
}
@Serializable
class SearchDto(
@SerialName("data")
val mangas: List<MangaDto>,
)
@Serializable
class PageDto(val `data`: Data) {
val pages: List<String> get() = `data`.lsDetail.source.split("#")
@Serializable
class Data(val lsDetail: LsDetail)
@Serializable
class LsDetail(val source: String)
}

View File

@ -7,9 +7,9 @@ class UnionMangasFactory : SourceFactory {
override fun createSources(): List<Source> = languages.map { UnionMangas(it) } override fun createSources(): List<Source> = languages.map { UnionMangas(it) }
} }
class LanguageOption(val lang: String, val infix: String = lang, val chpPrefix: String, val pageDelimiter: String) class LanguageOption(val lang: String, val infix: String = lang, val mangaSubstring: String = infix)
val languages = listOf( val languages = listOf(
LanguageOption("it", "italy", "leer", ","), LanguageOption("pt-BR", "manga-br"),
LanguageOption("pt-BR", "manga-br", "cap", "#"), LanguageOption("ru", "manga-ru", "mangas"),
) )