Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HotManga source. #6992

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/ru/hotmanga/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ext {
extName = 'HotManga'
extClass = '.HotManga'
extVersionCode = 1
isNsfw = true
}

apply from: "$rootDir/common.gradle"
Binary file added src/ru/hotmanga/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ru/hotmanga/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ru/hotmanga/res/mipmap-xhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ru/hotmanga/res/mipmap-xxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
package eu.kanade.tachiyomi.extension.ru.hotmanga

import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import android.widget.Toast
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.ru.hotmanga.dto.MangaDto
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.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.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.net.URI
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit

class HotManga : ConfigurableSource, HttpSource() {

override val id = 2073023199372375753
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why set this explicitly?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension won't working if it's not specified.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would if you don't access the preference during class initialization, (namely for baseurl)


private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}

private val baseOrig: String = "https://hotmanga.me"

private val baseMirr: String = "https://xn--80aaaklzpjd4c4a.xn--p1ai" // https://мангахентай.рф

private val baseMirrSecond: String = "https://xn--80aaalhzvfe9b4a.xn--80asehdb" // https://хентайманга.онлайн

private val baseMirrThird: String = "https://xn--80aanrbklcdf5b7a.xn--p1ai" // https://хентайонлайн.рф

private val apiPath = "/api"

private val domain: String? = preferences.getString(DOMAIN_PREF, baseOrig)

private val paidSymbol = "\uD83D\uDCB2"
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved

override val baseUrl = domain.toString()

override val lang = "ru"

override val name = "HotManga"

override val supportsLatest = true

private val apiPathsMap = mapOf(
baseOrig to apiPath,
baseMirr to "/api-frontend",
baseMirrSecond to apiPath,
baseMirrThird to apiPath,
)

override val client = network.cloudflareClient.newBuilder()
.rateLimit(3)
.connectTimeout(5, TimeUnit.MINUTES)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS).build()

override fun headersBuilder() = super.headersBuilder()
.set("Referer", baseUrl)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Referer header has a trailing slash.

Suggested change
.set("Referer", baseUrl)
.set("Referer", "$baseUrl/")

.set("Host", baseUrl.replace("https://", ""))

private val json: Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}

override fun popularMangaRequest(page: Int): Request {
val pageF = page - 1
val apiPathVal = apiPathsMap[baseUrl]
val apiString = "$apiPathVal/catalog?orderBy=-likes&page=$pageF"
val url = "${baseUrl}$apiString".toHttpUrl().newBuilder()
return GET(url.build(), headers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just pass a string to GET()

}

override fun popularMangaParse(response: Response): MangasPage {
if (response.code != 200) {
return MangasPage(emptyList(), false)
}
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved

val values = json.parseToJsonElement(response.body.string()).jsonArray
val mangaListDto = mutableListOf<MangaDto>()
for (item in values) {
try {
mangaListDto.add(json.decodeFromJsonElement<MangaDto>(item))
} catch (e: Exception) {
Log.i("HotManga", e.toString())
}
}
var hasNextPage = true
if (mangaListDto.isEmpty()) {
hasNextPage = false
}
val mangas = mutableListOf<SManga>()
for (mangaItem in mangaListDto) {
val element = mangaItem.toSManga()
mangas.add(element)
}
return MangasPage(mangas, hasNextPage)
Comment on lines +97 to +115
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val mangas = json.parseToJsonElement(response.body.string())
    .jsonArray
    .map(json::decodeFromJsonElement<MangaDto>)
    .map(MangaDto::toSManga)

return MangasPage(mangas, mangas.isNotEmpty())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to keep this because some titles from HotManga source have some broken data. We need to skip them or it will show nothing if we will use streams.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then you should be able to do something like:

val mangas = json.parseToJsonElement(response.body.string())
    .jsonArray
    .mapNotNull {
        runCatching {
            json.decodeFromJsonElement<MangaDto>(it)
        }.getOrNull()
    }
    .map(MangaDto::toSManga) // or move into try block

return MangasPage(mangas, mangas.isNotEmpty())

I did not check if it passes lint.

}

override fun getMangaUrl(manga: SManga): String {
return baseUrl + cleanMangaUrlFromBookIdParameter(manga.url)
}

private fun MangaDto.toSManga(): SManga =
SManga.create().apply {
title = titleEn ?: slug
url = "/manga/$slug?bookId=$id&countChapters=$countChapters"
// Original host does not work for some locations. Cloudflare protection. Need to change domain.
// Parameters w and q need to be calculated.
thumbnail_url = "$baseMirrThird/_next/image?url=$baseMirrThird$imageHigh&w=768&q=75"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use HttpUrlBuilder to escape arguments properly.

description = desc?.trim()
}

override fun chapterListParse(response: Response): List<SChapter> = throw NotImplementedError("Unused")

override fun imageUrlParse(response: Response): String = throw NotImplementedError("Unused")

override fun latestUpdatesParse(response: Response): MangasPage {
if (response.code != 200) {
return MangasPage(emptyList(), false)
}

val values = json.parseToJsonElement(response.body.string()).jsonArray
val mangaListDto = mutableListOf<MangaDto>()
for (item in values) {
try {
mangaListDto.add(json.decodeFromJsonElement<MangaDto>(item))
} catch (e: Exception) {
Log.i("HotManga", e.toString())
}
}
var hasNextPage = true
if (mangaListDto.isEmpty()) {
hasNextPage = false
}
val mangas = mutableListOf<SManga>()
for (mangaItem in mangaListDto) {
val element = mangaItem.toSManga()
mangas.add(element)
}
return MangasPage(mangas, hasNextPage)
}
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved

override fun latestUpdatesRequest(page: Int): Request {
val pageF = page - 1
val apiPathVal = apiPathsMap[baseUrl]
val apiString = "$apiPathVal/catalog?orderBy=-id&page=$pageF"
val url = "${baseUrl}$apiString".toHttpUrl().newBuilder()
return GET(url.build(), headers)
}

override fun mangaDetailsParse(response: Response): SManga = throw NotImplementedError("Unused")

override fun pageListParse(response: Response): List<Page> = throw NotImplementedError("Unused")

override fun searchMangaParse(response: Response): MangasPage {
if (response.code != 200) {
return MangasPage(emptyList(), false)
}

val values = json.parseToJsonElement(response.body.string()).jsonArray
val mangaListDto = mutableListOf<MangaDto>()
for (item in values) {
try {
mangaListDto.add(json.decodeFromJsonElement<MangaDto>(item))
} catch (e: Exception) {
Log.i("HotManga", e.toString())
}
}
var hasNextPage = true
if (mangaListDto.isEmpty()) {
hasNextPage = false
}
val mangas = mutableListOf<SManga>()
for (mangaItem in mangaListDto) {
val element = mangaItem.toSManga()
mangas.add(element)
}
return MangasPage(mangas, hasNextPage)
}
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved

override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
// TODO Add filters, use page param, investigate limit param
val apiPathVal = apiPathsMap[baseUrl]
val apiString = "$apiPathVal/books/search?filter[query]=$query&limit=24"
val url = "${baseUrl}$apiString".toHttpUrl().newBuilder()
return GET(url.build(), headers)
}

override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using the fetch* methods.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which method I should use instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use the functions used in the parent fetch* methods, if the site structure allows it. So:

  • Instead of fetchPopularManga, you should use popularMangaRequest and popularMangaParse
  • Instead of fetchSearchManga, you should use searchMangaRequest and searchMangaParse
  • Instead of fetchLatestUpdates, you should use latestUpdatesRequest and latestUpdatesParse
  • Instead of fetchMangaDetails, you should use mangaDetailsRequest and mangaDetailsParse
  • Instead of fetchChapterList, you should use chapterListRequest and chapterListParse
  • Instead of fetchPageList, you should use pageListRequest and pageListParse
  • Instead of fetchImageUrl, you should use imageUrlRequest and imageUrlParse

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks. I'll rework it...

val chapters = mutableListOf<SChapter>()
var page = 0
val mangaUrl = manga.url
val bookId = URI(mangaUrl).findParameterValue("bookId")
val countChapters = URI(mangaUrl).findParameterValue("countChapters")?.toLong()
val apiPathVal = apiPathsMap[baseUrl]
while (chapters.size < countChapters!!) {
val urlBase =
"$baseUrl$apiPathVal/chapters/with-branches?filter%5BbookId%5D=$bookId&page=$page"
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved
val request = GET(urlBase.toHttpUrl(), headers)
val body = client.newCall(request).execute().body.string()
val values = json.parseToJsonElement(body).jsonArray

if (values.isEmpty()) {
break
}

for (item in values) {
val number = item.jsonObject["number"].toString().replace("\"", "").toFloat()
val createdAt = item.jsonObject["createdAt"]?.jsonPrimitive?.content
val tom = item.jsonObject["tom"]?.jsonPrimitive?.content
val id = item.jsonObject["id"].toString()
val chapterBranches = item.jsonObject["chapterBranches"]?.jsonArray
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a DTO instead of this. You can use this extension for help with that: https://plugins.jetbrains.com/plugin/7834-dto-generator

var branchId = "0"
var isSubscription = false
if (chapterBranches != null) {
branchId = chapterBranches[0].jsonObject["branchId"].toString()
isSubscription = chapterBranches[0].jsonObject["isSubscription"]?.jsonPrimitive?.content.toBoolean()
}
val cleanUrl = cleanMangaUrlFromBookIdParameter(mangaUrl)
val chapterUrl = "$cleanUrl/ch$id?branchId=$branchId"
val parseDate = parseDate(createdAt)
var chapterName = "$tom. Глава $number"
if (isSubscription) {
chapterName += paidSymbol
}
val sChapter = SChapter.create().apply {
url = chapterUrl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use setUrlWithoutDomain() to allow for easier domain swapping in the future.

name = chapterName
date_upload = parseDate
chapter_number = number
}
chapters.add(sChapter)
}
++page
}
return Observable.just(chapters)
}

private val simpleDateFormat by lazy {
SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
Locale.US,
)
}

private fun parseDate(date: String?): Long {
date ?: return Date().time
return try {
simpleDateFormat.parse(date)!!.time
} catch (_: Exception) {
Date().time
}
}

override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val list = mutableListOf<Page>()
val pageUrl = "$baseUrl${chapter.url}"
val chapterPage = client.newCall(GET(pageUrl.toHttpUrl(), headers)).execute().asJsoup()
val elements = chapterPage.select("div.relative")
for (elem in elements) {
val imgElem = elem.select("img")
val imgSrc = imgElem.attr("src")
if (imgSrc.isNotEmpty()) {
list.add(Page(list.size, "", imgSrc))
}
}
return Observable.just(list)
}

override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)

private fun cleanMangaUrlFromBookIdParameter(url: String) = url.split("?")[0]

private fun URI.findParameterValue(parameterName: String): String? {
return rawQuery.split('&').map {
val parts = it.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.drop(1).firstOrNull() ?: ""
Pair(name, value)
}.firstOrNull { it.first == parameterName }?.second
}
ProgrammingForFun2021 marked this conversation as resolved.
Show resolved Hide resolved

override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = DOMAIN_PREF
title = "Выбор домена"
entries = arrayOf("Основной (hotmanga.me)", "Зеркало (мангахентай.рф)", "Зеркало 2 (хентайманга.онлайн)", "Зеркало 3 (хентайонлайн.рф)")
entryValues = arrayOf(baseOrig, baseMirr, baseMirrSecond, baseMirrThird)
summary = "%s"
setDefaultValue(baseOrig)
setOnPreferenceChangeListener { _, newValue ->
val warning =
"Для смены домена необходимо перезапустить приложение с полной остановкой."
Toast.makeText(screen.context, warning, Toast.LENGTH_LONG).show()
true
}
}.let(screen::addPreference)
}

companion object {
private const val DOMAIN_PREF = "HMMangaDomain"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.extension.ru.hotmanga.dto

import kotlinx.serialization.Serializable

@Serializable
data class MangaDto(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data class MangaDto(
class MangaDto(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also remove unused fields

val id: Long,
val lastChapterId: Long?,
val lastChapterBranchId: Long?,
val slug: String,
val type: String,
val title: String,
val alternativeTitle: String?,
val titleEn: String?,
val desc: String?,
val robotDesc: String?,
val imageMid: String?,
val imageLow: String?,
val imageHigh: String,
val isAccessRu: Boolean,
val isSubscription: Boolean,
val isYaoi: Boolean,
val isSafe: Boolean,
val isHomo: Boolean,
val isHentai: Boolean,
val isYuri: Boolean,
val isConfirm: Boolean,
val needUploadImage: Boolean,
val status: String,
val countChapters: Long,
val source: String,
val redirectUrl: String?,
val languageType: String,
val newUploadAt: String?,
val createdAt: String,
val updatedAt: String,
val createdRedirectUrlAt: String?,
)
Loading