From f3314852bdcab36be05e27c40dad6bf0eb0c805e Mon Sep 17 00:00:00 2001 From: Chopper <156493704+ttony2chopper@users.noreply.github.com> Date: Mon, 13 May 2024 12:34:28 -0300 Subject: [PATCH] HattoriManga: Theme change (#2960) * Migrate HattoriManga * Cleanup * Sorted chapters * Cleanup * Add searchManga * Cleanup * Remove unneeded code * Add search by intent * Cleanup * Add headers request * Fix names * Add nextPage * Add limit in nextPage * Fix AndroidManifest reference * Move rateLimit * Move fetchLatestUpdates parse to latestUpdatesParse * Remove setUrlWithoutDomain * Implements HttpSource * Cleanup * Fix author, artist and genres from mangaDetails * Update src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt * Update src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt --------- Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> --- src/tr/hattorimanga/AndroidManifest.xml | 22 ++ src/tr/hattorimanga/build.gradle | 4 +- .../extension/tr/hattorimanga/HattoriManga.kt | 286 +++++++++++++++++- .../tr/hattorimanga/HattoriMangaDto.kt | 57 ++++ .../hattorimanga/HattoriMangaUrlActivity.kt | 37 +++ 5 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 src/tr/hattorimanga/AndroidManifest.xml create mode 100644 src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaDto.kt create mode 100644 src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaUrlActivity.kt diff --git a/src/tr/hattorimanga/AndroidManifest.xml b/src/tr/hattorimanga/AndroidManifest.xml new file mode 100644 index 000000000..d284ea8ee --- /dev/null +++ b/src/tr/hattorimanga/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/tr/hattorimanga/build.gradle b/src/tr/hattorimanga/build.gradle index 814fd9080..22671169c 100644 --- a/src/tr/hattorimanga/build.gradle +++ b/src/tr/hattorimanga/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Hattori Manga' extClass = '.HattoriManga' - themePkg = 'madara' - baseUrl = 'https://hattorimanga.com' - overrideVersionCode = 0 + extVersionCode = 37 isNsfw = true } diff --git a/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt index b82fe12a3..9f5fe103a 100644 --- a/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt +++ b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriManga.kt @@ -1,23 +1,285 @@ package eu.kanade.tachiyomi.extension.tr.hattorimanga -import eu.kanade.tachiyomi.multisrc.madara.Madara +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +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.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat import java.util.Locale -class HattoriManga : Madara( - "Hattori Manga", - "https://hattorimanga.com", - "tr", - SimpleDateFormat("d MMM yyy", Locale("tr")), -) { - override fun pageListParse(document: Document): List { - val blocked = document.selectFirst(".content-blocked") - if (blocked != null) { - throw Exception(blocked.text()) // Bu bölümü okumak için Üye olmanız gerekiyor. +class HattoriManga : HttpSource() { + override val name: String = "Hattori Manga" + + override val baseUrl: String = "https://hattorimanga.com" + + override val lang: String = "tr" + + override val supportsLatest: Boolean = true + + override val versionId: Int = 2 + + private val json: Json by injectLazy() + + private var csrfToken: String = "" + + private var genresList: List = emptyList() + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + if (!request.url.toString().contains("manga/search")) { + return@addInterceptor chain.proceed(request) + } + + val req = request.newBuilder() + .addHeader("X-Requested-With", "XMLHttpRequest") + .build() + + if (csrfToken.isEmpty()) { + getCsrftoken() + } + + val query = request.url.fragment!! + val response = chain.proceed(addFormBody(req, query)) + + with(response) { + return@addInterceptor when { + isPageExpired() -> { + close() + getCsrftoken() + chain.proceed(addFormBody(req, query)) + } + else -> this + } + } + } + .rateLimit(4) + .build() + + private fun addFormBody(request: Request, query: String): Request { + val body = FormBody.Builder() + .add("_token", csrfToken) + .add("query", query) + .build() + + return request.newBuilder() + .url(request.url.toString().substringBefore("#")) + .post(body) + .build() + } + + private fun getCsrftoken() { + val response = client.newCall(GET(baseUrl, headers)).execute() + val document = response.asJsoup() + csrfToken = document.selectFirst("meta[name=csrf-token]")!!.attr("content") + } + + override fun chapterListParse(response: Response) = + throw UnsupportedOperationException() + + override fun fetchChapterList(manga: SManga): Observable> { + val slug = manga.url.substringAfterLast('/') + val chapters = mutableListOf() + var page = 1 + + do { + val dto = fetchChapterPageableList(slug, page, manga) + chapters += dto.chapters.map { + SChapter.create().apply { + name = it.title + date_upload = it.date.toDate() + url = "${manga.url}/${it.chapterSlug}" + } + } + page = dto.nextPage() + } while (dto.hasNextPage()) + + return Observable.just(chapters) + } + + private fun fetchChapterPageableList(slug: String, page: Int, manga: SManga): HMChapterDto = + client.newCall(GET("$baseUrl/load-more-chapters/$slug?page=$page", headers)) + .execute() + .parseAs() + + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest-chapters") + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + return SManga.create().apply { + title = document.selectFirst("h3")!!.text() + thumbnail_url = document.selectFirst(".set-bg")?.absUrl("data-setbg") + description = document.selectFirst(".anime-details-text p")?.text() + author = document.selectFirst(".anime-details-widget li:has(span:contains(Yazar))")?.ownText() + artist = document.selectFirst(".anime-details-widget li:has(span:contains(Çizer))")?.ownText() + genre = document.selectFirst(".anime-details-widget li:has(span:contains(Etiketler))") + ?.ownText() + ?.split(",") + ?.map { it.trim() } + ?.joinToString() + setUrlWithoutDomain(document.location()) + } + } + + override fun pageListParse(response: Response): List { + return response.asJsoup().select(".image-wrapper img").mapIndexed { index, element -> + Page(index, imageUrl = "$baseUrl${element.attr("data-src")}") + }.takeIf { it.isNotEmpty() } ?: throw Exception("Oturum açmanız, WebView'ı açmanız ve oturum açmanız gerekir") + } + + override fun latestUpdatesParse(response: Response): MangasPage { + return response.use { + val mangas = it.parseAs().chapters.map { + SManga.create().apply { + val manga = it.manga + title = manga.title + thumbnail_url = "$baseUrl/storage/${manga.thumbnail}" + url = "/manga/${manga.slug}" + } + }.distinctBy { manga -> manga.title } + MangasPage(mangas, false) + } + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + if (genresList.isEmpty()) { + genresList = parseGenres(document) } - return super.pageListParse(document) + val mangas = document + .select(".product-card.grow-box") + .map(::mangaFromElement) + + return MangasPage( + mangas = mangas, + hasNextPage = document.selectFirst(".pagination .page-item:last-child:not(.disabled)") != null, + ) + } + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?page=$page", headers) + + override fun searchMangaParse(response: Response) = popularMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val request = POST("$baseUrl/manga/search#$query", headers) + if (query.isNotBlank()) { + return request + } + + val url = "$baseUrl/manga-index".toHttpUrl().newBuilder() + val selection = filters.filterIsInstance() + .flatMap { it.state } + .filter { it.state } + + return when { + selection.isNotEmpty() -> { + selection.forEach { genre -> + url.addQueryParameter("genres[]", genre.id) + } + url.addQueryParameter("page", "$page") + GET(url.build(), headers) + } + else -> request + } + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(SEARCH_PREFIX)) { + val slug = query.removePrefix(SEARCH_PREFIX) + return client.newCall(GET("$baseUrl/$slug", headers)) + .asObservableSuccess() + .map { + MangasPage(listOf(mangaDetailsParse(it)), false) + } + } + + val request = searchMangaRequest(page, query, filters) + + if (request.url.toString().contains("manga-index")) { + return super.fetchSearchManga(page, query, filters) + } + + return client.newCall(request).asObservableSuccess().map { response -> + val mangas = response.parseAs>().map { + SManga.create().apply { + title = it.title + description = it.description + author = it.author + artist = it.artist + thumbnail_url = "$baseUrl/storage/${it.thumbnail}" + url = "/manga/${it.slug}" + } + } + MangasPage(mangas, false) + } + } + + override fun getFilterList(): FilterList { + val filters = mutableListOf>() + + filters += if (genresList.isNotEmpty()) { + GenreList("Türler", genresList) + } else { + Filter.Header("Türleri göstermeyi denemek için 'Sıfırla' düğmesine basın") + } + + return FilterList(filters) + } + + override fun imageUrlParse(response: Response) = "" + + private fun mangaFromElement(element: Element) = SManga.create().apply { + title = element.selectFirst("h5")!!.text() + thumbnail_url = element.selectFirst(".img-con")?.absUrl("data-setbg") + genre = element.select(".product-card-con ul li").joinToString { it.text() } + val script = element.attr("onclick") + setUrlWithoutDomain(REGEX_MANGA_URL.find(script)!!.groups["url"]!!.value) + } + + private fun parseGenres(document: Document): List { + return document.select(".tags-blog a") + .map { element -> Genre(element.text()) } + } + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + private fun Response.isPageExpired() = code == 419 + + private fun String.toDate(): Long = + try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L } + + class GenreList(title: String, genres: List) : Filter.Group(title, genres.map { GenreCheckBox(it.name, it.id) }) + + class GenreCheckBox(name: String, val id: String = name) : Filter.CheckBox(name) + + class Genre(val name: String, val id: String = name) + + companion object { + const val SEARCH_PREFIX = "slug:" + val REGEX_MANGA_URL = """='(?[^']+)""".toRegex() + val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US) } } diff --git a/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaDto.kt b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaDto.kt new file mode 100644 index 000000000..6f5843c56 --- /dev/null +++ b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaDto.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.extension.tr.hattorimanga + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.math.min + +@Serializable +class HMChapterDto( + val chapters: List, + val currentPage: Int, + val lastPage: Int, +) { + fun hasNextPage(): Boolean = currentPage < lastPage + + fun nextPage(): Int = min(lastPage, currentPage + 1) +} + +@Serializable +class ChapterDto( + @SerialName("title") + val title: String, + @SerialName("manga_slug") + val slug: String, + @SerialName("chapter_slug") + val chapterSlug: String, + @SerialName("formattedUploadTime") + val date: String, +) + +@Serializable +class HMLatestUpdateDto( + val chapters: List, +) + +@Serializable +class ChapterMangaDto( + val manga: LatestUpdateDto, +) + +@Serializable +class LatestUpdateDto( + val title: String, + val slug: String, + @SerialName("cover_image") + val thumbnail: String, +) + +@Serializable +class SearchManga( + val slug: String, + val title: String, + val description: String, + @SerialName("cover_image") + val thumbnail: String, + val author: String, + val artist: String, +) diff --git a/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaUrlActivity.kt b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaUrlActivity.kt new file mode 100644 index 000000000..43e3e67f1 --- /dev/null +++ b/src/tr/hattorimanga/src/eu/kanade/tachiyomi/extension/tr/hattorimanga/HattoriMangaUrlActivity.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.extension.tr.hattorimanga + +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 HattoriMangaUrlActivity : Activity() { + + private val tag = javaClass.simpleName + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val item = pathSegments[1] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${HattoriManga.SEARCH_PREFIX}$item") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e(tag, e.toString()) + } + } else { + Log.e(tag, "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}