-
Notifications
You must be signed in to change notification settings - Fork 545
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
base: main
Are you sure you want to change the base?
Add HotManga source. #6992
Changes from 2 commits
ecc67bd
a2ba93a
ff4f262
594f1d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" |
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 | ||||||
|
||||||
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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Referer header has a trailing slash.
Suggested change
|
||||||
.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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can just pass a string to |
||||||
} | ||||||
|
||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||||||
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>> { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid using the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which method I should use instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should use the functions used in the parent
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||||||
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( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?, | ||||||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why set this explicitly?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)