From 935bd089fc8d53d66d6d5abb803462f0819c3009 Mon Sep 17 00:00:00 2001 From: "Cuong M. Tran" Date: Sun, 28 Apr 2024 13:51:04 +0700 Subject: [PATCH] add multi-src: GalleryAdults (#2553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Convert AsmHentai into multisrc GalleryAdults Also convert some selector into function * Move HentaiFox to theme GalleryAdults * GalleryAdults: Fix search * MangaFox: fix TagFilter * fast page load & preference for slowly parsing image’s URL * AsmHentai: change shortTitle reference from list to swith * HentaiFox: add Korean * move HentaiFox from en to all * fix build * fix search: convert space to + * Request for tags list from site * Fix request for user’s favorites * - Optimize popular/latest request - Improve ‘page’ param - AsmHentai: support Latest/Popular * add SortFilter * Support multiple tags filter * Support exact match query * getTime * Fix Lang when searching * fix searchById * add language companion * Fix URL action * renovate * Support parsing json for page list Fix generating page if less than 10 pages HentaiFox: Random server selection * Migrate IMHentai to GalleryAdults * Preferences to support all methods for page querying * IMHentai: tagList * Expose some filters to child class, add more space to description * Fix Factory lang * Support browsing tags, speechless & favorite * IMHentai: - support favorite browsing (require login) - tag filter with queried popular tags - advanced search for artist, group, character, parody, tag (include/exclude) - remove language filters - Fix language search * Move advance search to multi-src * Fix: hide speechless when not supported * add Hint to use comma * split code to Filters & Utils * bump version all 3 extensions * fix getTime * fix lint * Fix alternative name * improve cleanTag * move out of Object * move Regex out * remove RandomUA * fix build * remove images parsing setting, pick a default one * fix build * Move shortTitle to base clash * HentaiFox: add language keyword to search query * if all mangas in current searching page is of other language then include at least 1 entry so it can request for next page * Alternative methods for images parsing Revert "remove images parsing setting, pick a default one" This reverts commit e49e3aaeb74e3643abc2e303924da18a52491793. # Conflicts: # lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt # src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt # src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt * Fall back if failed to decode JSON * remove supportLatest from base class * Remove preference for parsing page by page, switch to override val instead. * Split searchRequest into parts * Don't using generic Filter.Text to avoid other kind of text field which extensions might have --- .../galleryadults}/AndroidManifest.xml | 10 +- lib-multisrc/galleryadults/build.gradle.kts | 5 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2840 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1619 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3874 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6835 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 11190 bytes .../multisrc/galleryadults/GalleryAdults.kt | 850 ++++++++++++++++++ .../galleryadults/GalleryAdultsFilters.kt | 29 + .../galleryadults/GalleryAdultsUrlActivity.kt | 14 +- .../galleryadults/GalleryAdultsUtils.kt | 144 +++ src/all/asmhentai/build.gradle | 4 +- .../extension/all/asmhentai/ASMHFactory.kt | 9 +- .../extension/all/asmhentai/AsmHentai.kt | 322 ++----- src/all/hentaifox/build.gradle | 10 + .../hentaifox/res/mipmap-hdpi/ic_launcher.png | Bin .../hentaifox/res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher.png | Bin .../extension/all/hentaifox/HentaiFox.kt | 97 ++ .../all/hentaifox/HentaiFoxFactory.kt | 15 + src/all/imhentai/build.gradle | 4 +- .../extension/all/imhentai/IMHentai.kt | 376 +++----- .../extension/all/imhentai/IMHentaiFactory.kt | 16 +- src/en/hentaifox/build.gradle | 8 - .../extension/en/hentaifox/HentaiFox.kt | 223 ----- 27 files changed, 1364 insertions(+), 772 deletions(-) rename {src/all/asmhentai => lib-multisrc/galleryadults}/AndroidManifest.xml (71%) create mode 100644 lib-multisrc/galleryadults/build.gradle.kts create mode 100644 lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png create mode 100644 lib-multisrc/galleryadults/res/mipmap-mdpi/ic_launcher.png create mode 100644 lib-multisrc/galleryadults/res/mipmap-xhdpi/ic_launcher.png create mode 100644 lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 lib-multisrc/galleryadults/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt create mode 100644 lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt rename src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt => lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt (63%) create mode 100644 lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt create mode 100644 src/all/hentaifox/build.gradle rename src/{en => all}/hentaifox/res/mipmap-hdpi/ic_launcher.png (100%) rename src/{en => all}/hentaifox/res/mipmap-mdpi/ic_launcher.png (100%) rename src/{en => all}/hentaifox/res/mipmap-xhdpi/ic_launcher.png (100%) rename src/{en => all}/hentaifox/res/mipmap-xxhdpi/ic_launcher.png (100%) rename src/{en => all}/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png (100%) create mode 100644 src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt create mode 100644 src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt delete mode 100644 src/en/hentaifox/build.gradle delete mode 100644 src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt diff --git a/src/all/asmhentai/AndroidManifest.xml b/lib-multisrc/galleryadults/AndroidManifest.xml similarity index 71% rename from src/all/asmhentai/AndroidManifest.xml rename to lib-multisrc/galleryadults/AndroidManifest.xml index 9de41aa38..aefaa54ac 100644 --- a/src/all/asmhentai/AndroidManifest.xml +++ b/lib-multisrc/galleryadults/AndroidManifest.xml @@ -1,19 +1,21 @@ + + + android:host="${SOURCEHOST}" + android:pathPattern="/g.*/..*/" + android:scheme="${SOURCESCHEME}" /> diff --git a/lib-multisrc/galleryadults/build.gradle.kts b/lib-multisrc/galleryadults/build.gradle.kts new file mode 100644 index 000000000..7dd8f08b9 --- /dev/null +++ b/lib-multisrc/galleryadults/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 0 diff --git a/lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..3762b52b3e5e98a8fb2dbd6b93ecd9decbb70c7e GIT binary patch literal 2840 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 literal 0 HcmV?d00001 diff --git a/lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..fd6c94e4fe0bb27359c1780d258be7168205530a GIT binary patch literal 6835 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 Zj#vJ9x1?Ymj;O~4D9WnJRDCe}_CKNo#{d8T literal 0 HcmV?d00001 diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt new file mode 100644 index 000000000..891961ea5 --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt @@ -0,0 +1,850 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import android.app.Application +import android.content.SharedPreferences +import android.util.Log +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +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.model.UpdateStrategy +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import okhttp3.FormBody +import okhttp3.HttpUrl +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.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat + +abstract class GalleryAdults( + override val name: String, + override val baseUrl: String, + override val lang: String = "all", + protected open val mangaLang: String = LANGUAGE_MULTI, + protected val simpleDateFormat: SimpleDateFormat? = null, +) : ConfigurableSource, ParsedHttpSource() { + + override val client: OkHttpClient = network.cloudflareClient + + protected open val xhrHeaders = headers.newBuilder() + .add("X-Requested-With", "XMLHttpRequest") + .build() + + protected val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + protected val SharedPreferences.shortTitle + get() = getBoolean(PREF_SHORT_TITLE, false) + + private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + SwitchPreferenceCompat(screen.context).apply { + key = PREF_SHORT_TITLE + title = "Display Short Titles" + summaryOff = "Showing Long Titles" + summaryOn = "Showing short Titles" + setDefaultValue(false) + }.also(screen::addPreference) + } + + protected open fun Element.mangaTitle(selector: String = ".caption"): String? = + mangaFullTitle(selector).let { + if (preferences.shortTitle) it?.shortenTitle() else it + } + + protected fun Element.mangaFullTitle(selector: String) = + selectFirst(selector)?.text() + + private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim() + + /* List detail */ + protected class SMangaDto( + val title: String, + val url: String, + val thumbnail: String?, + val lang: String, + ) + + protected open fun Element.mangaUrl() = + selectFirst(".inner_thumb a")?.attr("abs:href") + + protected open fun Element.mangaThumbnail() = + selectFirst(".inner_thumb img")?.imgAttr() + + // Overwrite this to filter other languages' manga from search result. + // Default to [mangaLang] won't filter anything + protected open fun Element.mangaLang() = mangaLang + + protected open fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder { + val url = toString() + if (!url.endsWith('/') && !url.contains('?')) { + addPathSegment("") // trailing slash (/) + } + if (page > 1) addQueryParameter("page", page.toString()) + return this + } + + /* Popular */ + override fun popularMangaRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang") + if (supportsLatest) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + override fun popularMangaSelector() = "div.thumb" + + override fun popularMangaFromElement(element: Element): SManga { + return SManga.create().apply { + title = element.mangaTitle()!! + setUrlWithoutDomain(element.mangaUrl()!!) + thumbnail_url = element.mangaThumbnail() + } + } + + override fun popularMangaNextPageSelector() = ".pagination li.active + li:not(.disabled)" + + /* Latest */ + override fun latestUpdatesRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang") + addPageUri(page) + } + return GET(url.build(), headers) + } + + override fun latestUpdatesSelector() = popularMangaSelector() + + override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + /* Search */ + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return when { + query.startsWith(PREFIX_ID_SEARCH) -> { + val id = query.removePrefix(PREFIX_ID_SEARCH) + client.newCall(searchMangaByIdRequest(id)) + .asObservableSuccess() + .map { response -> searchMangaByIdParse(response, id) } + } + query.toIntOrNull() != null -> { + client.newCall(searchMangaByIdRequest(query)) + .asObservableSuccess() + .map { response -> searchMangaByIdParse(response, query) } + } + else -> { + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> searchMangaParse(response) } + } + } + } + + protected open val idPrefixUri = "g" + + protected open fun searchMangaByIdRequest(id: String): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment(idPrefixUri) + addPathSegments("$id/") + } + return GET(url.build(), headers) + } + + protected open fun searchMangaByIdParse(response: Response, id: String): MangasPage { + val details = mangaDetailsParse(response.asJsoup()) + details.url = "/$idPrefixUri/$id/" + return MangasPage(listOf(details), false) + } + + protected open val useIntermediateSearch: Boolean = false + protected open val supportAdvancedSearch: Boolean = false + protected open val supportSpeechless: Boolean = false + private val useBasicSearch: Boolean + get() = !useIntermediateSearch + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + val favoriteFilter = filters.filterIsInstance().firstOrNull() + + // Speechless + val speechlessFilter = filters.filterIsInstance().firstOrNull() + + // Advanced search + val advancedSearchFilters = filters.filterIsInstance() + + return when { + favoriteFilter?.state == true -> + favoriteFilterSearchRequest(page, query, filters) + supportSpeechless && speechlessFilter?.state == true -> + speechlessFilterSearchRequest(page, query, filters) + supportAdvancedSearch && advancedSearchFilters.any { it.state.isNotBlank() } -> + advancedSearchRequest(page, query, filters) + selectedGenres.size == 1 && query.isBlank() -> + tagBrowsingSearchRequest(page, query, filters) + useIntermediateSearch -> + intermediateSearchRequest(page, query, filters) + useBasicSearch && (selectedGenres.size > 1 || query.isNotBlank()) -> + basicSearchRequest(page, query, filters) + sortOrderFilter?.state == 1 -> + latestUpdatesRequest(page) + else -> + popularMangaRequest(page) + } + } + + /** + * Browsing user's personal favorites saved on site. This requires login in view WebView. + */ + protected open fun favoriteFilterSearchRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/$favoritePath".toHttpUrl().newBuilder() + return POST( + url.build().toString(), + xhrHeaders, + FormBody.Builder() + .add("page", page.toString()) + .build(), + ) + } + + /** + * Browsing speechless titles. Some sites exclude speechless titles from normal search and + * allow browsing separately. + */ + protected open fun speechlessFilterSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("language") + addPathSegment(LANGUAGE_SPEECHLESS) + if (sortOrderFilter?.state == 0) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + protected open fun tagBrowsingSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Browsing single tag's catalog + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("tag") + addPathSegment(selectedGenres.single().uri) + if (sortOrderFilter?.state == 0) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + /** + * Basic Search: support query string with multiple-genres filter by adding genres to query string. + */ + protected open fun basicSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments("search/") + addEncodedQueryParameter("q", buildQueryString(selectedGenres.map { it.name }, query)) + // Search results sorting is not supported by AsmHentai + if (sortOrderFilter?.state == 0) addQueryParameter("sort", "popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + /** + * This supports filter query search with languages, categories (manga, doujinshi...) + * with additional sort orders. + */ + protected open fun intermediateSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Intermediate search + val categoryFilters = filters.filterIsInstance().firstOrNull() + + // Only for query string or multiple tags + val url = "$baseUrl/search".toHttpUrl().newBuilder().apply { + getSortOrderURIs().forEachIndexed { index, pair -> + addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index)) + } + categoryFilters?.state?.forEach { + addQueryParameter(it.uri, toBinary(it.state)) + } + getLanguageURIs().forEach { pair -> + addQueryParameter( + pair.second, + toBinary(mangaLang == pair.first || mangaLang == LANGUAGE_MULTI), + ) + } + addEncodedQueryParameter("key", buildQueryString(selectedGenres.map { it.name }, query)) + addPageUri(page) + } + return GET(url.build()) + } + + /** + * Advanced Search normally won't support search for string but allow include/exclude specific + * tags/artists/groups/parodies/characters + */ + protected open fun advancedSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Intermediate search + val categoryFilters = filters.filterIsInstance().firstOrNull() + // Advanced search + val advancedSearchFilters = filters.filterIsInstance() + + val url = "$baseUrl/advsearch".toHttpUrl().newBuilder().apply { + getSortOrderURIs().forEachIndexed { index, pair -> + addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index)) + } + categoryFilters?.state?.forEach { + addQueryParameter(it.uri, toBinary(it.state)) + } + getLanguageURIs().forEach { pair -> + addQueryParameter( + pair.second, + toBinary( + mangaLang == pair.first || + mangaLang == LANGUAGE_MULTI, + ), + ) + } + + // Build this query string: +tag:"bat+man"+-tag:"cat"+artist:"Joe"... + // +tag must be encoded into %2Btag while the rest are not needed to encode + val keys = emptyList().toMutableList() + keys.addAll(selectedGenres.map { "%2Btag:\"${it.name}\"" }) + advancedSearchFilters.forEach { filter -> + val key = when (filter) { + is TagsFilter -> "tag" + is ParodiesFilter -> "parody" + is ArtistsFilter -> "artist" + is CharactersFilter -> "character" + is GroupsFilter -> "group" + else -> null + } + if (key != null) { + keys.addAll( + filter.state.trim() + .replace(regexSpaceNotAfterComma, "+") + .replace(" ", "") + .split(',') + .mapNotNull { + val match = regexExcludeTerm.find(it) + match?.groupValues?.let { groups -> + "${if (groups[1].isNotBlank()) "-" else "%2B"}$key:\"${groups[2]}\"" + } + }, + ) + } + } + addEncodedQueryParameter("key", keys.joinToString("+")) + addPageUri(page) + } + return GET(url.build()) + } + + /** + * Convert space( ) typed in search-box into plus(+) in URL. Then: + * - uses plus(+) to search for exact match + * - use comma(,) for separate terms, as AND condition. + * Plus(+) after comma(,) doesn't have any effect. + */ + protected open fun buildQueryString(tags: List, query: String): String { + return (tags + query).filterNot { it.isBlank() }.joinToString(",") { + // any space except after a comma (we're going to replace spaces only between words) + it.trim() + .replace(regexSpaceNotAfterComma, "+") + .replace(" ", "") + } + } + + protected open val favoritePath = "includes/user_favs.php" + + protected open fun loginRequired(document: Document, url: String): Boolean { + return ( + url.contains("/login/") && + document.select("input[value=Login]").isNotEmpty() + ) + } + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + if (loginRequired(document, response.request.url.toString())) { + throw Exception("Log in via WebView to view favorites") + } else { + val hasNextPage = document.select(searchMangaNextPageSelector()).isNotEmpty() + val mangas = document.select(searchMangaSelector()) + .map { + SMangaDto( + title = it.mangaTitle()!!, + url = it.mangaUrl()!!, + thumbnail = it.mangaThumbnail(), + lang = it.mangaLang(), + ) + } + .let { unfiltered -> + val results = unfiltered.filter { mangaLang.isBlank() || it.lang == mangaLang } + // return at least 1 title if all mangas in current page is of other languages + if (results.isEmpty() && hasNextPage) listOf(unfiltered[0]) else results + } + .map { + SManga.create().apply { + title = it.title + setUrlWithoutDomain(it.url) + thumbnail_url = it.thumbnail + } + } + + return MangasPage(mangas, hasNextPage) + } + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + /* Details */ + protected open fun Element.getCover() = + selectFirst(".cover img")?.imgAttr() + + protected open fun Element.getInfo(tag: String): String { + return select("ul.${tag.lowercase()} a") + .joinToString { it.ownText() } + } + + protected open fun Element.getDescription(): String = ( + listOf("Parodies", "Characters", "Languages", "Categories") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".pages:contains(Pages:)")?.ownText(), + ) + ) + .joinToString("\n\n") + + protected open val mangaDetailInfoSelector = ".gallery_top" + protected open val timeSelector = "time[datetime]" + + protected open fun Element.getTime(): Long { + return selectFirst(timeSelector) + ?.attr("datetime") + .toDate(simpleDateFormat) + } + + override fun mangaDetailsParse(document: Document): SManga { + return document.selectFirst(mangaDetailInfoSelector)!!.run { + SManga.create().apply { + update_strategy = UpdateStrategy.ONLY_FETCH_ONCE + status = SManga.COMPLETED + title = mangaTitle("h1")!! + thumbnail_url = getCover() + genre = getInfo("Tags") + author = getInfo("Artists") + description = getDescription() + } + } + } + + /* Chapters */ + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return listOf( + SChapter.create().apply { + name = "Chapter" + scanlator = document.selectFirst(mangaDetailInfoSelector) + ?.getInfo("Groups") + date_upload = document.getTime() + setUrlWithoutDomain(response.request.url.encodedPath) + }, + ) + } + + override fun chapterListSelector() = throw UnsupportedOperationException() + + override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() + + /* Pages */ + protected open fun Document.inputIdValueOf(string: String): String { + return select("input[id=$string]").attr("value") + } + + protected open val galleryIdSelector = "gallery_id" + protected open val loadIdSelector = "load_id" + protected open val loadDirSelector = "load_dir" + protected open val totalPagesSelector = "load_pages" + protected open val pageUri = "g" + protected open val pageSelector = ".gallery_thumb" + + protected open val pagesRequest = "inc/thumbs_loader.php" + + private val jsonFormat: Json by injectLazy() + + protected open fun getServer(document: Document, galleryId: String): String { + val cover = document.getCover() + return cover!!.toHttpUrl().host + } + + override fun pageListParse(document: Document): List { + val json = document.selectFirst("script:containsData(parseJSON)")?.data() + ?.substringAfter("$.parseJSON('") + ?.substringBefore("');")?.trim() + + if (json != null) { + val loadDir = document.inputIdValueOf(loadDirSelector) + val loadId = document.inputIdValueOf(loadIdSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val randomServer = getServer(document, galleryId) + val imagesUri = "https://$randomServer/$loadDir/$loadId" + + try { + val pages = mutableListOf() + val images = jsonFormat.parseToJsonElement(json).jsonObject + + // JSON string in this form: {"1":"j,1100,1148","2":"j,728,689",... + for (image in images) { + val ext = image.value.toString().replace("\"", "").split(",")[0] + val imageExt = when (ext) { + "p" -> "png" + "b" -> "bmp" + "g" -> "gif" + else -> "jpg" + } + val idx = image.key.toInt() + pages.add( + Page( + index = idx, + imageUrl = "$imagesUri/${image.key}.$imageExt", + url = "$pageUrl/$idx/", + ), + ) + } + return pages + } catch (e: SerializationException) { + Log.e("GalleryAdults", "Failed to decode JSON") + return this.pageListParseAlternative(document) + } + } else { + return this.pageListParseAlternative(document) + } + } + + /** + * Overwrite this to force extension not blindly converting thumbnails to full image + * with simply removing the trailing "t" from file name. Instead, it will open each page, + * one by one, then parsing for actual image's URL. + * This will be much slower but guaranteed work. + */ + protected open val parsingImagePageByPage: Boolean = false + + /** + * Either: + * - Load all thumbnails then convert thumbnails to full images. + * - Or request then parse for a list of manga's page's URL, + * which will then request one by one to parse for page's image's URL using [imageUrlParse]. + */ + protected open fun pageListParseAlternative(document: Document): List { + // input only exists if pages > 10 and have to make a request to get the other thumbnails + val totalPages = document.inputIdValueOf(totalPagesSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val pages = document.select("$pageSelector a") + .map { + if (parsingImagePageByPage) { + it.absUrl("href") + } else { + it.selectFirst("img")!!.imgAttr() + } + } + .toMutableList() + + if (totalPages.isNotBlank()) { + val form = pageRequestForm(document, totalPages) + + val morePages = client.newCall(POST("$baseUrl/$pagesRequest", xhrHeaders, form)) + .execute() + .asJsoup() + .select("a") + .map { + if (parsingImagePageByPage) { + it.absUrl("href") + } else { + it.selectFirst("img")!!.imgAttr() + } + } + if (morePages.isNotEmpty()) { + pages.addAll(morePages) + } else { + return pageListParseDummy(document) + } + } + + return pages.mapIndexed { idx, url -> + if (parsingImagePageByPage) { + Page(idx, url) + } else { + Page( + index = idx, + imageUrl = url.thumbnailToFull(), + url = "$pageUrl/$idx/", + ) + } + } + } + + /** + * Generate all images using `totalPages`. Supposedly they are sequential. + * Use in case any extension doesn't know how to request for "All thumbnails" + */ + protected open fun pageListParseDummy(document: Document): List { + val loadDir = document.inputIdValueOf(loadDirSelector) + val loadId = document.inputIdValueOf(loadIdSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val randomServer = getServer(document, galleryId) + val imagesUri = "https://$randomServer/$loadDir/$loadId" + + val images = document.select("$pageSelector img") + val thumbUrls = images.map { it.imgAttr() }.toMutableList() + + // totalPages only exists if pages > 10 and have to make a request to get the other thumbnails + val totalPages = document.inputIdValueOf(totalPagesSelector) + + if (totalPages.isNotBlank()) { + val imagesExt = images.first()?.imgAttr()!! + .substringAfterLast('.') + + thumbUrls.addAll( + listOf((images.size + 1)..totalPages.toInt()).flatten().map { + "$imagesUri/${it}t.$imagesExt" + }, + ) + } + return thumbUrls.mapIndexed { idx, url -> + Page( + index = idx, + imageUrl = url.thumbnailToFull(), + url = "$pageUrl/$idx/", + ) + } + } + + protected open fun pageRequestForm(document: Document, totalPages: String): FormBody = + FormBody.Builder() + .add("u_id", document.inputIdValueOf(galleryIdSelector)) + .add("g_id", document.inputIdValueOf(loadIdSelector)) + .add("img_dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("total_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() + + override fun imageUrlParse(document: Document): String { + return document.selectFirst("img#gimg, img#fimg")?.imgAttr()!! + } + + /* Filters */ + private val scope = CoroutineScope(Dispatchers.IO) + private fun launchIO(block: () -> Unit) = scope.launch { block() } + private var tagsFetchAttempt = 0 + private var genres = emptyList() + + private fun tagsRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments("tags/popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + protected open fun tagsParser(document: Document): List> { + return document.select(".list_tags .tag_item") + .mapNotNull { + Pair( + it.selectFirst("h3.list_tag")?.ownText() ?: "", + it.select("a").attr("href") + .removeSuffix("/").substringAfterLast('/'), + ) + } + } + + private fun getGenres() { + if (genres.isEmpty() && tagsFetchAttempt < 3) { + launchIO { + val tags = mutableListOf>() + runBlocking { + val jobsPool = mutableListOf() + // Get first 3 pages + (1..3).forEach { page -> + jobsPool.add( + launchIO { + runCatching { + tags.addAll( + client.newCall(tagsRequest(page)) + .execute().asJsoup().let { tagsParser(it) }, + ) + } + }, + ) + } + jobsPool.joinAll() + genres = tags.sortedWith(compareBy { it.first }).map { Genre(it.first, it.second) } + } + + tagsFetchAttempt++ + } + } + } + + override fun getFilterList(): FilterList { + getGenres() + val filters = emptyList>().toMutableList() + if (useIntermediateSearch) { + filters.add(Filter.Header("HINT: Separate search term with comma (,)")) + } + + filters.add(SortOrderFilter(getSortOrderURIs())) + + if (genres.isEmpty()) { + filters.add(Filter.Header("Press 'reset' to attempt to load tags")) + } else { + filters.add(GenresFilter(genres)) + } + + if (useIntermediateSearch) { + filters.addAll( + listOf( + Filter.Separator(), + CategoryFilters(getCategoryURIs()), + ), + ) + } + + if (supportAdvancedSearch) { + filters.addAll( + listOf( + Filter.Separator(), + Filter.Header("Advanced filters will ignore query search. Separate terms by comma (,) and precede term with minus (-) to exclude."), + TagsFilter(), + ParodiesFilter(), + ArtistsFilter(), + CharactersFilter(), + GroupsFilter(), + ), + ) + } + + filters.add(Filter.Separator()) + + if (supportSpeechless) { + filters.add(SpeechlessFilter()) + } + filters.add(FavoriteFilter()) + + return FilterList(filters) + } + + protected open fun getSortOrderURIs() = listOf( + Pair("Popular", "pp"), + Pair("Latest", "lt"), + ) + if (useIntermediateSearch || supportAdvancedSearch) { + listOf( + Pair("Downloads", "dl"), + Pair("Top Rated", "tr"), + ) + } else { + emptyList() + } + + protected open fun getCategoryURIs() = listOf( + SearchFlagFilter("Manga", "m"), + SearchFlagFilter("Doujinshi", "d"), + SearchFlagFilter("Western", "w"), + SearchFlagFilter("Image Set", "i"), + SearchFlagFilter("Artist CG", "a"), + SearchFlagFilter("Game CG", "g"), + ) + + protected open fun getLanguageURIs() = listOf( + Pair(LANGUAGE_ENGLISH, "en"), + Pair(LANGUAGE_JAPANESE, "jp"), + Pair(LANGUAGE_SPANISH, "es"), + Pair(LANGUAGE_FRENCH, "fr"), + Pair(LANGUAGE_KOREAN, "kr"), + Pair(LANGUAGE_GERMAN, "de"), + Pair(LANGUAGE_RUSSIAN, "ru"), + ) + + companion object { + const val PREFIX_ID_SEARCH = "id:" + + private const val PREF_SHORT_TITLE = "pref_short_title" + + // references to be used in factory + const val LANGUAGE_MULTI = "" + const val LANGUAGE_ENGLISH = "english" + const val LANGUAGE_JAPANESE = "japanese" + const val LANGUAGE_CHINESE = "chinese" + const val LANGUAGE_KOREAN = "korean" + const val LANGUAGE_SPANISH = "spanish" + const val LANGUAGE_FRENCH = "french" + const val LANGUAGE_GERMAN = "german" + const val LANGUAGE_RUSSIAN = "russian" + const val LANGUAGE_SPEECHLESS = "speechless" + const val LANGUAGE_TRANSLATED = "translated" + } +} diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt new file mode 100644 index 000000000..23008a3e0 --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import eu.kanade.tachiyomi.source.model.Filter + +class Genre(name: String, val uri: String) : Filter.CheckBox(name) +class GenresFilter(genres: List) : Filter.Group( + "Tags", + genres.map { Genre(it.name, it.uri) }, +) + +class SortOrderFilter(sortOrderURIs: List>) : + Filter.Select("Sort By", sortOrderURIs.map { it.first }.toTypedArray()) + +class FavoriteFilter : Filter.CheckBox("Show favorites only (login via WebView)", false) + +// Speechless +class SpeechlessFilter : Filter.CheckBox("Show speechless items only", false) + +// Intermediate search +class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state) +class CategoryFilters(flags: List) : Filter.Group("Categories", flags) + +// Advance search +abstract class AdvancedTextFilter(name: String) : Filter.Text(name) +class TagsFilter : AdvancedTextFilter("Tags") +class ParodiesFilter : AdvancedTextFilter("Parodies") +class ArtistsFilter : AdvancedTextFilter("Artists") +class CharactersFilter : AdvancedTextFilter("Characters") +class GroupsFilter : AdvancedTextFilter("Groups") diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt similarity index 63% rename from src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt rename to lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt index c984eae77..0aa18cd52 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.all.asmhentai +package eu.kanade.tachiyomi.multisrc.galleryadults import android.app.Activity import android.content.ActivityNotFoundException @@ -8,27 +8,27 @@ import android.util.Log import kotlin.system.exitProcess /** - * Springboard that accepts https://asmhentai.com/g/xxxxxx intents and redirects them to - * the main Tachiyomi process. + * Springboard that accepts https:///g/xxxxxx intents and redirects them to main app process. */ -class ASMHUrlActivity : Activity() { +class GalleryAdultsUrlActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val pathSegments = intent?.data?.pathSegments if (pathSegments != null && pathSegments.size > 1) { + val id = pathSegments[1] val mainIntent = Intent().apply { action = "eu.kanade.tachiyomi.SEARCH" - putExtra("query", "${AsmHentai.PREFIX_ID_SEARCH}${pathSegments[1]}") + putExtra("query", "${GalleryAdults.PREFIX_ID_SEARCH}$id") putExtra("filter", packageName) } try { startActivity(mainIntent) } catch (e: ActivityNotFoundException) { - Log.e("ASMHUrlActivity", e.toString()) + Log.e("GalleryAdultsUrl", e.toString()) } } else { - Log.e("ASMHUrlActivity", "could not parse uri from intent $intent") + Log.e("GalleryAdultsUrl", "could not parse uri from intent $intent") } finish() diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt new file mode 100644 index 000000000..49c84465f --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt @@ -0,0 +1,144 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar + +// any space except after a comma (we're going to replace spaces only between words) +val regexSpaceNotAfterComma = Regex("""(? absUrl("data-cfsrc") + hasAttr("data-src") -> absUrl("data-src") + hasAttr("data-lazy-src") -> absUrl("data-lazy-src") + hasAttr("srcset") -> absUrl("srcset").substringBefore(" ") + else -> absUrl("src") +}!! + +fun Element.cleanTag(): String = text().cleanTag() +fun String.cleanTag(): String = replace(regexTagCountNumber, "").trim() + +// convert thumbnail URLs to full image URLs +fun String.thumbnailToFull(): String { + val ext = substringAfterLast(".") + return replace("t.$ext", ".$ext") +} + +fun String?.toDate(simpleDateFormat: SimpleDateFormat?): Long { + this ?: return 0L + + return if (simpleDateFormat != null) { + if (contains(regexDateSuffix)) { + // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it + split(" ").map { + if (it.contains(regexDate)) { + it.replace(regexNotNumber, "") + } else { + it + } + } + .let { simpleDateFormat.tryParse(it.joinToString(" ")) } + } else { + simpleDateFormat.tryParse(this) + } + } else { + parseDate(this) + } +} + +private fun parseDate(date: String?): Long { + date ?: return 0 + + return when { + // Handle 'yesterday' and 'today', using midnight + WordSet("yesterday", "يوم واحد").startsWith(date) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) // yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("today", "just now").startsWith(date) -> { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("يومين").startsWith(date) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -2) // day before yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> { + parseRelativeDate(date) + } + WordSet("hace").startsWith(date) -> { + parseRelativeDate(date) + } + else -> 0L + } +} + +// Parses dates in this form: 21 hours ago OR "2 days ago (Updated 19 hours ago)" +private fun parseRelativeDate(date: String): Long { + val number = regexRelativeDateTime.find(date)?.value?.toIntOrNull() + ?: date.split(" ").firstOrNull() + ?.replace("one", "1") + ?.replace("a", "1") + ?.toIntOrNull() + ?: return 0L + val now = Calendar.getInstance() + + // Sort by order + return when { + WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) -> + now.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) -> + now.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) -> + now.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "天").anyWordIn(date) -> + now.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("week", "semana").anyWordIn(date) -> + now.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + WordSet("month", "mes").anyWordIn(date) -> + now.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year", "año").anyWordIn(date) -> + now.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0L + } +} + +private fun SimpleDateFormat.tryParse(string: String): Long { + return try { + parse(string)?.time ?: 0L + } catch (_: ParseException) { + 0L + } +} + +class WordSet(private vararg val words: String) { + fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) } + fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) } + fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) } +} + +fun toBinary(boolean: Boolean) = if (boolean) "1" else "0" diff --git a/src/all/asmhentai/build.gradle b/src/all/asmhentai/build.gradle index e73783778..26c5c5507 100644 --- a/src/all/asmhentai/build.gradle +++ b/src/all/asmhentai/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'AsmHentai' extClass = '.ASMHFactory' - extVersionCode = 1 + themePkg = 'galleryadults' + baseUrl = 'https://asmhentai.com' + overrideVersionCode = 2 isNsfw = true } diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt index 41a2563d5..17aaeb430 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt +++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt @@ -1,13 +1,14 @@ package eu.kanade.tachiyomi.extension.all.asmhentai +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory class ASMHFactory : SourceFactory { override fun createSources(): List = listOf( - AsmHentai("en", "english"), - AsmHentai("ja", "japanese"), - AsmHentai("zh", "chinese"), - AsmHentai("all", ""), + AsmHentai("en", GalleryAdults.LANGUAGE_ENGLISH), + AsmHentai("ja", GalleryAdults.LANGUAGE_JAPANESE), + AsmHentai("zh", GalleryAdults.LANGUAGE_CHINESE), + AsmHentai("all", GalleryAdults.LANGUAGE_MULTI), ) } diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt index f470fca67..d03c4b239 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt +++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt @@ -1,274 +1,102 @@ package eu.kanade.tachiyomi.extension.all.asmhentai -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag +import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr 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.model.UpdateStrategy -import eu.kanade.tachiyomi.source.online.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup 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 -open class AsmHentai(override val lang: String, private val tlTag: String) : ParsedHttpSource() { +class AsmHentai( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "AsmHentai", + "https://asmhentai.com", + lang = lang, +) { + override val supportsLatest = mangaLang.isNotBlank() - override val client: OkHttpClient = network.cloudflareClient + override fun Element.mangaUrl() = + selectFirst(".image a")?.attr("abs:href") - override val baseUrl = "https://asmhentai.com" + override fun Element.mangaThumbnail() = + selectFirst(".image img")?.imgAttr() - override val name = "AsmHentai" + override fun Element.mangaLang() = + select("a:has(.flag)").attr("href") + .removeSuffix("/").substringAfterLast("/") - override val supportsLatest = false + override fun popularMangaSelector() = ".preview_item" - // Popular - - override fun popularMangaRequest(page: Int): Request { - val url = baseUrl.toHttpUrl().newBuilder().apply { - if (tlTag.isNotEmpty()) addPathSegments("language/$tlTag/") - if (page > 1) addQueryParameter("page", page.toString()) - } - return GET(url.build(), headers) + override fun Element.getInfo(tag: String): String { + return select(".tags:contains($tag:) .tag") + .joinToString { it.ownText().cleanTag() } } - override fun popularMangaSelector(): String = ".preview_item" - - private fun Element.mangaTitle() = select("h2").text() - - private fun Element.mangaUrl() = select(".image a").attr("abs:href") - - private fun Element.mangaThumbnail() = select(".image img").attr("abs:src") - - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - title = element.mangaTitle() - setUrlWithoutDomain(element.mangaUrl()) - thumbnail_url = element.mangaThumbnail() - } + override fun Element.getDescription(): String { + return ( + listOf("Parodies", "Characters", "Languages", "Category") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".book_page .pages h3")?.ownText(), + selectFirst(".book_page h1 + h2")?.ownText() + .let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null }, + ) + ) + .joinToString("\n\n") + .plus( + if (preferences.shortTitle) { + "\nFull title: ${mangaFullTitle("h1")}" + } else { + "" + }, + ) } - override fun popularMangaNextPageSelector(): String = "li.active + li:not(.disabled)" + /* Search */ + override val favoritePath = "inc/user.php?act=favs" - // Latest + override val mangaDetailInfoSelector = ".book_page" - override fun latestUpdatesNextPageSelector(): String? { - throw UnsupportedOperationException() + override val galleryIdSelector = "load_id" + override val totalPagesSelector = "t_pages" + override val pageUri = "gallery" + override val pageSelector = ".preview_thumb" + + override fun pageRequestForm(document: Document, totalPages: String): FormBody { + val token = document.select("[name=csrf-token]").attr("content") + + return FormBody.Builder() + .add("_token", token) + .add("id", document.inputIdValueOf(loadIdSelector)) + .add("dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("t_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() } - override fun latestUpdatesRequest(page: Int): Request { - throw UnsupportedOperationException() - } - - override fun latestUpdatesFromElement(element: Element): SManga { - throw UnsupportedOperationException() - } - - override fun latestUpdatesSelector(): String { - throw UnsupportedOperationException() - } - - // Search - - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - return when { - query.startsWith(PREFIX_ID_SEARCH) -> { - val id = query.removePrefix(PREFIX_ID_SEARCH) - client.newCall(searchMangaByIdRequest(id)) - .asObservableSuccess() - .map { response -> searchMangaByIdParse(response, id) } - } - query.toIntOrNull() != null -> { - client.newCall(searchMangaByIdRequest(query)) - .asObservableSuccess() - .map { response -> searchMangaByIdParse(response, query) } - } - else -> super.fetchSearchManga(page, query, filters) - } - } - - // any space except after a comma (we're going to replace spaces only between words) - private val spaceRegex = Regex("""(? query - query.isBlank() -> tags - else -> "$query,$tags" - }.replace(spaceRegex, "+") - - val url = baseUrl.toHttpUrl().newBuilder().apply { - addPathSegments("search/") - addEncodedQueryParameter("q", q) - if (page > 1) addQueryParameter("page", page.toString()) - } - - return GET(url.build(), headers) - } - - private class SMangaDto( - val title: String, - val url: String, - val thumbnail: String, - val lang: String, - ) - - override fun searchMangaParse(response: Response): MangasPage { - val doc = response.asJsoup() - - val mangas = doc.select(searchMangaSelector()) - .map { - SMangaDto( - title = it.mangaTitle(), - url = it.mangaUrl(), - thumbnail = it.mangaThumbnail(), - lang = it.select("a:has(.flag)").attr("href").removeSuffix("/").substringAfterLast("/"), + /* Filters */ + override fun tagsParser(document: Document): List> { + return document.select(".tags_page ul.tags li") + .mapNotNull { + Pair( + it.selectFirst("a.tag")?.ownText() ?: "", + it.select("a.tag").attr("href") + .removeSuffix("/").substringAfterLast('/'), ) } - .let { unfiltered -> - if (tlTag.isNotEmpty()) unfiltered.filter { it.lang == tlTag } else unfiltered - } - .map { - SManga.create().apply { - title = it.title - setUrlWithoutDomain(it.url) - thumbnail_url = it.thumbnail - } - } - - return MangasPage(mangas, doc.select(searchMangaNextPageSelector()).isNotEmpty()) } - private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/g/$id/", headers) - - private fun searchMangaByIdParse(response: Response, id: String): MangasPage { - val details = mangaDetailsParse(response) - details.url = "/g/$id/" - return MangasPage(listOf(details), false) - } - - override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - // Details - - private fun Element.get(tag: String): String { - return select(".tags:contains($tag) .tag").joinToString { it.ownText() } - } - - override fun mangaDetailsParse(document: Document): SManga { - return SManga.create().apply { - update_strategy = UpdateStrategy.ONLY_FETCH_ONCE - document.select(".book_page").first()!!.let { element -> - thumbnail_url = element.select(".cover img").attr("abs:src") - title = element.select("h1").text() - genre = element.get("Tags") - artist = element.get("Artists") - author = artist - description = listOf("Parodies", "Groups", "Languages", "Category") - .mapNotNull { tag -> - element.get(tag).let { if (it.isNotEmpty()) "$tag: $it" else null } - } - .joinToString("\n", postfix = "\n") + - element.select(".pages h3").text() + - element.select("h1 + h2").text() - .let { altTitle -> if (altTitle.isNotEmpty()) "\nAlternate Title: $altTitle" else "" } - } - } - } - - // Chapters - - override fun fetchChapterList(manga: SManga): Observable> { - return Observable.just( - listOf( - SChapter.create().apply { - name = "Chapter" - url = manga.url - }, - ), - ) - } - - override fun chapterListSelector(): String { - throw UnsupportedOperationException() - } - - override fun chapterFromElement(element: Element): SChapter { - throw UnsupportedOperationException() - } - - // Pages - - // convert thumbnail URLs to full image URLs - private fun String.full(): String { - val fType = substringAfterLast("t") - return replace("t$fType", fType) - } - - private fun Document.inputIdValueOf(string: String): String { - return select("input[id=$string]").attr("value") - } - - override fun pageListParse(document: Document): List { - val thumbUrls = document.select(".preview_thumb img") - .map { it.attr("abs:data-src") } - .toMutableList() - - // input only exists if pages > 10 and have to make a request to get the other thumbnails - val totalPages = document.inputIdValueOf("t_pages") - - if (totalPages.isNotEmpty()) { - val token = document.select("[name=csrf-token]").attr("content") - - val form = FormBody.Builder() - .add("_token", token) - .add("id", document.inputIdValueOf("load_id")) - .add("dir", document.inputIdValueOf("load_dir")) - .add("visible_pages", "10") - .add("t_pages", totalPages) - .add("type", "2") // 1 would be "more", 2 is "all remaining" - .build() - - val xhrHeaders = headers.newBuilder() - .add("X-Requested-With", "XMLHttpRequest") - .build() - - client.newCall(POST("$baseUrl/inc/thumbs_loader.php", xhrHeaders, form)) - .execute() - .asJsoup() - .select("img") - .mapTo(thumbUrls) { it.attr("abs:data-src") } - } - return thumbUrls.mapIndexed { i, url -> Page(i, "", url.full()) } - } - - override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() - - // Filters - - override fun getFilterList(): FilterList = FilterList( - Filter.Header("Separate tags with commas (,)"), - TagFilter(), + override fun getFilterList() = FilterList( + listOf( + Filter.Header("HINT: Separate search term with comma (,)"), + ) + super.getFilterList().list, ) - - class TagFilter : Filter.Text("Tags") - - companion object { - const val PREFIX_ID_SEARCH = "id:" - } } diff --git a/src/all/hentaifox/build.gradle b/src/all/hentaifox/build.gradle new file mode 100644 index 000000000..20ecdab90 --- /dev/null +++ b/src/all/hentaifox/build.gradle @@ -0,0 +1,10 @@ +ext { + extName = 'HentaiFox' + extClass = '.HentaiFoxFactory' + themePkg = 'galleryadults' + baseUrl = 'https://hentaifox.com' + overrideVersionCode = 6 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/hentaifox/res/mipmap-hdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-hdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-hdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-mdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-mdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-mdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xhdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xxhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xxhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xxhdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt new file mode 100644 index 000000000..698aad26f --- /dev/null +++ b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt @@ -0,0 +1,97 @@ +package eu.kanade.tachiyomi.extension.all.hentaifox + +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.toDate +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import kotlin.random.Random + +class HentaiFox( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "HentaiFox", + "https://hentaifox.com", + lang = lang, + mangaLang = mangaLang, + simpleDateFormat = null, +) { + override val supportsLatest = mangaLang.isNotBlank() + + private val languages: List> = listOf( + Pair(LANGUAGE_ENGLISH, "1"), + Pair(LANGUAGE_TRANSLATED, "2"), + Pair(LANGUAGE_JAPANESE, "5"), + Pair(LANGUAGE_CHINESE, "6"), + Pair(LANGUAGE_KOREAN, "11"), + ) + private val langCode = languages.firstOrNull { lang -> lang.first == mangaLang }?.second + + override fun Element.mangaLang() = attr("data-languages") + .split(' ').let { + when { + it.contains(langCode) -> mangaLang + // search result doesn't have "data-languages" which will return a list with 1 blank element + it.size > 1 || (it.size == 1 && it.first().isNotBlank()) -> "other" + // if we don't know which language to filter then set to mangaLang to not filter at all + else -> mangaLang + } + } + + override fun Element.mangaTitle(selector: String): String? = mangaFullTitle(selector) + + override fun Element.getTime(): Long { + return selectFirst(".pages:contains(Posted:)")?.ownText() + ?.removePrefix("Posted: ") + .toDate(simpleDateFormat) + } + + override fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder { + val url = toString() + when { + url == "$baseUrl/" && page == 2 -> + addPathSegments("page/$page") + url.contains('?') -> + addQueryParameter("page", page.toString()) + page > 1 -> + addPathSegments("pag/$page") + } + addPathSegment("") // trailing slash (/) + return this + } + + /* Pages */ + override val pagesRequest = "includes/thumbs_loader.php" + + override fun getServer(document: Document, galleryId: String): String { + val domain = baseUrl.toHttpUrl().host + // Randomly choose between servers + return if (Random.nextBoolean()) "i2.$domain" else "i.$domain" + } + + /** + * Convert space( ) typed in search-box into plus(+) in URL. Then: + * - ignore the word preceding by a special character (e.g. 'school-girl' will ignore 'girl') + * => replace to plus(+), + * - use plus(+) for separate terms, as AND condition. + * - use double quote(") to search for exact match. + */ + override fun buildQueryString(tags: List, query: String): String { + val regexSpecialCharacters = Regex("""[^a-zA-Z0-9"]+(?=[a-zA-Z0-9"])""") + return (tags + query + mangaLang).filterNot { it.isBlank() }.joinToString("+") { + it.trim().replace(regexSpecialCharacters, "+") + } + } + + override fun getFilterList() = FilterList( + listOf( + Filter.Header("HINT: Use double quote (\") for exact match"), + ) + super.getFilterList().list, + ) + + override val idPrefixUri = "gallery" +} diff --git a/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt new file mode 100644 index 000000000..45c2621b2 --- /dev/null +++ b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.hentaifox + +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class HentaiFoxFactory : SourceFactory { + override fun createSources(): List = listOf( + HentaiFox("en", GalleryAdults.LANGUAGE_ENGLISH), + HentaiFox("ja", GalleryAdults.LANGUAGE_JAPANESE), + HentaiFox("zh", GalleryAdults.LANGUAGE_CHINESE), + HentaiFox("ko", GalleryAdults.LANGUAGE_KOREAN), + HentaiFox("all", GalleryAdults.LANGUAGE_MULTI), + ) +} diff --git a/src/all/imhentai/build.gradle b/src/all/imhentai/build.gradle index 8aba20a5a..bccb1a0b2 100644 --- a/src/all/imhentai/build.gradle +++ b/src/all/imhentai/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'IMHentai' extClass = '.IMHentaiFactory' - extVersionCode = 14 + themePkg = 'galleryadults' + baseUrl = 'https://imhentai.xxx' + overrideVersionCode = 15 isNsfw = true } diff --git a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt index 96cd5eb80..2e544da9c 100644 --- a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt +++ b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt @@ -1,33 +1,37 @@ package eu.kanade.tachiyomi.extension.all.imhentai -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservableSuccess -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.ParsedHttpSource -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag +import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr +import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import org.jsoup.select.Elements -import rx.Observable -import uy.kohesive.injekt.injectLazy import java.io.IOException -class IMHentai(override val lang: String, private val imhLang: String) : ParsedHttpSource() { - - override val baseUrl: String = "https://imhentai.xxx" - override val name: String = "IMHentai" +class IMHentai( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "IMHentai", + "https://imhentai.xxx", + lang = lang, +) { override val supportsLatest = true + override val useIntermediateSearch: Boolean = true + override val supportAdvancedSearch: Boolean = true + override val supportSpeechless: Boolean = true + + override fun Element.mangaLang() = + select("a:has(.thumb_flag)").attr("href") + .removeSuffix("/").substringAfterLast("/") + .let { + // Include Speechless in search results + if (it == LANGUAGE_SPEECHLESS) mangaLang else it + } override val client: OkHttpClient = network.cloudflareClient .newBuilder() @@ -57,271 +61,103 @@ class IMHentai(override val lang: String, private val imhLang: String) : ParsedH }, ).build() - // Popular + override val favoritePath = "user/fav_pags.php" - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - thumbnail_url = element.selectFirst(".inner_thumb img")?.let { - it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src") + /* Details */ + override fun Element.getInfo(tag: String): String { + return select("li:has(.tags_text:contains($tag:)) .tag").map { + it?.run { + listOf( + ownText().cleanTag(), + select(".split_tag").text() + .trim() + .removePrefix("| ") + .cleanTag(), + ) + .filter { s -> s.isNotBlank() } + .joinToString() } - with(element.select(".caption a")) { - url = this.attr("href") - title = this.text() - } - } + }.joinToString() } - override fun popularMangaNextPageSelector(): String = ".pagination li a:contains(Next):not([tabindex])" - - override fun popularMangaSelector(): String = ".thumbs_container .thumb" - - override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_POPULAR)) - - // Latest - - override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector() - - override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_LATEST)) - - override fun latestUpdatesSelector(): String = popularMangaSelector() - - // Search - - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - if (query.startsWith("id:")) { - val id = query.substringAfter("id:") - return client.newCall(GET("$baseUrl/gallery/$id/")) - .asObservableSuccess() - .map { response -> - val manga = mangaDetailsParse(response) - manga.url = "/gallery/$id/" - MangasPage(listOf(manga), false) - } - } - return super.fetchSearchManga(page, query, filters) - } - - override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector() - - private fun toBinary(boolean: Boolean) = if (boolean) "1" else "0" - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - if (filters.any { it is LanguageFilters && it.state.any { it.name == LANGUAGE_SPEECHLESS && it.state } }) { // edge case for language = speechless - val url = "$baseUrl/language/speechless/".toHttpUrl().newBuilder() - - if ((if (filters.isEmpty()) getFilterList() else filters).filterIsInstance()[0].state == 0) { - url.addPathSegment("popular") - } - return GET(url.build()) - } else { - val url = "$baseUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("key", query) - .addQueryParameter("page", page.toString()) - .addQueryParameter(getLanguageURIByName(imhLang).uri, toBinary(true)) // main language always enabled - - (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> - when (filter) { - is LanguageFilters -> { - filter.state.forEach { - url.addQueryParameter(it.uri, toBinary(it.state)) - } - } - is CategoryFilters -> { - filter.state.forEach { - url.addQueryParameter(it.uri, toBinary(it.state)) - } - } - is SortOrderFilter -> { - getSortOrderURIs().forEachIndexed { index, pair -> - url.addQueryParameter(pair.second, toBinary(filter.state == index)) - } - } - else -> {} - } - } - return GET(url.build()) - } - } - - override fun searchMangaSelector(): String = popularMangaSelector() - - // Details - - private fun Elements.csvText(splitTagSeparator: String = ", "): String { - return this.joinToString { - listOf( - it.ownText(), - it.select(".split_tag").text() - .trim() - .removePrefix("| "), + override fun Element.getDescription(): String { + return ( + listOf("Parodies", "Characters", "Languages", "Category") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".pages")?.ownText(), + selectFirst(".subtitle")?.ownText() + .let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null }, + ) ) - .filter { s -> !s.isNullOrBlank() } - .joinToString(splitTagSeparator) + .joinToString("\n\n") + .plus( + if (preferences.shortTitle) { + "\nFull title: ${mangaFullTitle("h1")}" + } else { + "" + }, + ) + } + + override fun Element.getCover() = + selectFirst(".left_cover img")?.imgAttr() + + override val mangaDetailInfoSelector = ".gallery_first" + + /* Pages */ + override val pageUri = "view" + override val pageSelector = ".gthumb" + private val serverSelector = "load_server" + + private fun serverNumber(document: Document, galleryId: String): String { + return document.inputIdValueOf(serverSelector).takeIf { + it.isNotBlank() + } ?: when (galleryId.toInt()) { + in 1..274825 -> "1" + in 274826..403818 -> "2" + in 403819..527143 -> "3" + in 527144..632481 -> "4" + in 632482..816010 -> "5" + in 816011..970098 -> "6" + in 970099..1121113 -> "7" + else -> "8" } } - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - title = document.selectFirst("div.right_details > h1")!!.text() - - thumbnail_url = document.selectFirst("div.left_cover img")?.let { - it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src") - } - - val mangaInfoElement = document.select(".galleries_info") - val infoMap = mangaInfoElement.select("li:not(.pages)").associate { - it.select("span.tags_text").text().removeSuffix(":") to it.select(".tag") - } - - artist = infoMap["Artists"]?.csvText(" | ") - - author = artist - - genre = infoMap["Tags"]?.csvText() - - status = SManga.COMPLETED - - val pages = mangaInfoElement.select("li.pages").text().substringAfter("Pages: ") - val altTitle = document.select(".subtitle").text().ifBlank { null } - - description = listOf( - "Parodies", - "Characters", - "Groups", - "Languages", - "Category", - ).map { it to infoMap[it]?.csvText() } - .let { listOf(Pair("Alternate Title", altTitle)) + it + listOf(Pair("Pages", pages)) } - .filter { !it.second.isNullOrEmpty() } - .joinToString("\n\n") { "${it.first}:\n${it.second}" } + override fun getServer(document: Document, galleryId: String): String { + val domain = baseUrl.toHttpUrl().host + return "m${serverNumber(document, galleryId)}.$domain" } - // Chapters + override fun pageRequestForm(document: Document, totalPages: String): FormBody { + val galleryId = document.inputIdValueOf(galleryIdSelector) - override fun chapterListParse(response: Response): List { - return listOf( - SChapter.create().apply { - setUrlWithoutDomain(response.request.url.toString().replace("gallery", "view") + "1") - name = "Chapter" - chapter_number = 1f - }, - ) + return FormBody.Builder() + .add("server", serverNumber(document, galleryId)) + .add("u_id", document.inputIdValueOf(galleryIdSelector)) + .add("g_id", document.inputIdValueOf(loadIdSelector)) + .add("img_dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("total_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() } - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() - - override fun chapterListSelector(): String = throw UnsupportedOperationException() - - // Pages - - private val json: Json by injectLazy() - - override fun pageListParse(document: Document): List { - val imageDir = document.select("#image_dir").`val`() - val galleryId = document.select("#gallery_id").`val`() - val uId = document.select("#u_id").`val`().toInt() - - val randomServer = when (uId) { - in 1..274825 -> "m1.imhentai.xxx" - in 274826..403818 -> "m2.imhentai.xxx" - in 403819..527143 -> "m3.imhentai.xxx" - in 527144..632481 -> "m4.imhentai.xxx" - in 632482..816010 -> "m5.imhentai.xxx" - in 816011..970098 -> "m6.imhentai.xxx" - in 970099..1121113 -> "m7.imhentai.xxx" - else -> "m8.imhentai.xxx" - } - - val images = json.parseToJsonElement( - document.selectFirst("script:containsData(var g_th)")!!.data() - .substringAfter("$.parseJSON('").substringBefore("');").trim(), - ).jsonObject - val pages = mutableListOf() - - for (image in images) { - val iext = image.value.toString().replace("\"", "").split(",")[0] - val iextPr = when (iext) { - "p" -> "png" - "b" -> "bmp" - "g" -> "gif" - else -> "jpg" + /* Filters */ + override fun tagsParser(document: Document): List> { + return document.select(".stags .tag_btn") + .mapNotNull { + Pair( + it.selectFirst(".list_tag")?.ownText() ?: "", + it.select("a").attr("href") + .removeSuffix("/").substringAfterLast('/'), + ) } - pages.add(Page(image.key.toInt() - 1, "", "https://$randomServer/$imageDir/$galleryId/${image.key}.$iextPr")) - } - return pages } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() - - // Filters - - private class SortOrderFilter(sortOrderURIs: List>, state: Int) : - Filter.Select("Sort By", sortOrderURIs.map { it.first }.toTypedArray(), state) - - private open class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state) - private class LanguageFilter(name: String, uri: String = name) : SearchFlagFilter(name, uri, false) - private class LanguageFilters(flags: List) : Filter.Group("Other Languages", flags) - private class CategoryFilters(flags: List) : Filter.Group("Categories", flags) - - override fun getFilterList() = getFilterList(SORT_ORDER_DEFAULT) - - private fun getFilterList(sortOrderState: Int) = FilterList( - SortOrderFilter(getSortOrderURIs(), sortOrderState), - CategoryFilters(getCategoryURIs()), - LanguageFilters(getLanguageURIs().filter { it.name != imhLang }), // exclude main lang - Filter.Header("Speechless language: ignores all filters except \"Popular\" and \"Latest\" in Sorting Filter"), - ) - - private fun getCategoryURIs() = listOf( - SearchFlagFilter("Manga", "manga"), - SearchFlagFilter("Doujinshi", "doujinshi"), - SearchFlagFilter("Western", "western"), - SearchFlagFilter("Image Set", "imageset"), - SearchFlagFilter("Artist CG", "artistcg"), - SearchFlagFilter("Game CG", "gamecg"), - ) - - // update sort order indices in companion object if order is changed - private fun getSortOrderURIs() = listOf( - Pair("Popular", "pp"), - Pair("Latest", "lt"), - Pair("Downloads", "dl"), - Pair("Top Rated", "tr"), - ) - - private fun getLanguageURIs() = listOf( - LanguageFilter(LANGUAGE_ENGLISH, "en"), - LanguageFilter(LANGUAGE_JAPANESE, "jp"), - LanguageFilter(LANGUAGE_SPANISH, "es"), - LanguageFilter(LANGUAGE_FRENCH, "fr"), - LanguageFilter(LANGUAGE_KOREAN, "kr"), - LanguageFilter(LANGUAGE_GERMAN, "de"), - LanguageFilter(LANGUAGE_RUSSIAN, "ru"), - LanguageFilter(LANGUAGE_SPEECHLESS, ""), - ) - - private fun getLanguageURIByName(name: String): LanguageFilter { - return getLanguageURIs().first { it.name == name } - } - - companion object { - - // references to sort order indices - private const val SORT_ORDER_POPULAR = 0 - private const val SORT_ORDER_LATEST = 1 - private const val SORT_ORDER_DEFAULT = SORT_ORDER_POPULAR - - // references to be used in factory - const val LANGUAGE_ENGLISH = "English" - const val LANGUAGE_JAPANESE = "Japanese" - const val LANGUAGE_SPANISH = "Spanish" - const val LANGUAGE_FRENCH = "French" - const val LANGUAGE_KOREAN = "Korean" - const val LANGUAGE_GERMAN = "German" - const val LANGUAGE_RUSSIAN = "Russian" - const val LANGUAGE_SPEECHLESS = "Speechless" - } + override val idPrefixUri = "gallery" } diff --git a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt index b6b7add53..a3aa72078 100644 --- a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt +++ b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt @@ -1,17 +1,19 @@ package eu.kanade.tachiyomi.extension.all.imhentai +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory class IMHentaiFactory : SourceFactory { override fun createSources(): List = listOf( - IMHentai("en", IMHentai.LANGUAGE_ENGLISH), - IMHentai("ja", IMHentai.LANGUAGE_JAPANESE), - IMHentai("es", IMHentai.LANGUAGE_SPANISH), - IMHentai("fr", IMHentai.LANGUAGE_FRENCH), - IMHentai("ko", IMHentai.LANGUAGE_KOREAN), - IMHentai("de", IMHentai.LANGUAGE_GERMAN), - IMHentai("ru", IMHentai.LANGUAGE_RUSSIAN), + IMHentai("en", GalleryAdults.LANGUAGE_ENGLISH), + IMHentai("ja", GalleryAdults.LANGUAGE_JAPANESE), + IMHentai("es", GalleryAdults.LANGUAGE_SPANISH), + IMHentai("fr", GalleryAdults.LANGUAGE_FRENCH), + IMHentai("ko", GalleryAdults.LANGUAGE_KOREAN), + IMHentai("de", GalleryAdults.LANGUAGE_GERMAN), + IMHentai("ru", GalleryAdults.LANGUAGE_RUSSIAN), + IMHentai("all", GalleryAdults.LANGUAGE_MULTI), ) } diff --git a/src/en/hentaifox/build.gradle b/src/en/hentaifox/build.gradle deleted file mode 100644 index 56d8b1706..000000000 --- a/src/en/hentaifox/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -ext { - extName = 'HentaiFox' - extClass = '.HentaiFox' - extVersionCode = 5 - isNsfw = true -} - -apply from: "$rootDir/common.gradle" diff --git a/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt b/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt deleted file mode 100644 index 4301bd986..000000000 --- a/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt +++ /dev/null @@ -1,223 +0,0 @@ -package eu.kanade.tachiyomi.extension.en.hentaifox - -import eu.kanade.tachiyomi.network.GET -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.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import rx.Observable - -class HentaiFox : ParsedHttpSource() { - - override val name = "HentaiFox" - - override val baseUrl = "https://hentaifox.com" - - override val lang = "en" - - override val supportsLatest = false - - override val client: OkHttpClient = network.cloudflareClient - - // Popular - - override fun popularMangaRequest(page: Int): Request { - return if (page == 2) { - GET("$baseUrl/page/$page/", headers) - } else { - GET("$baseUrl/pag/$page/", headers) - } - } - - override fun popularMangaSelector() = "div.thumb" - - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - element.select("h2 a").let { - title = it.text() - setUrlWithoutDomain(it.attr("href")) - } - thumbnail_url = element.selectFirst("img")!!.imgAttr() - } - } - - override fun popularMangaNextPageSelector() = "li.page-item:last-of-type:not(.disabled)" - - // Latest - - override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException() - - override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException() - - override fun latestUpdatesSelector() = throw UnsupportedOperationException() - - override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException() - - override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException() - - // Search - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - return if (query.isNotEmpty()) { - GET("$baseUrl/search/?q=$query&page=$page", headers) - } else { - var url = "$baseUrl/tag/" - - filters.forEach { filter -> - when (filter) { - is GenreFilter -> { - url += "${filter.toUriPart()}/pag/$page/" - } - else -> {} - } - } - GET(url, headers) - } - } - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - // Details - - override fun mangaDetailsParse(document: Document): SManga { - return document.select("div.gallery_top").let { info -> - SManga.create().apply { - title = info.select("h1").text() - genre = info.select("ul.tags a").joinToString { it.ownText() } - artist = info.select("ul.artists a").joinToString { it.ownText() } - thumbnail_url = info.select("img").first()!!.imgAttr() - description = info.select("ul.parodies a") - .let { e -> if (e.isNotEmpty()) "Parodies: ${e.joinToString { it.ownText() }}\n\n" else "" } - description += info.select("ul.characters a") - .let { e -> if (e.isNotEmpty()) "Characters: ${e.joinToString { it.ownText() }}\n\n" else "" } - description += info.select("ul.groups a") - .let { e -> if (e.isNotEmpty()) "Groups: ${e.joinToString { it.ownText() }}\n\n" else "" } - } - } - } - - // Chapters - - override fun chapterListParse(response: Response): List { - return listOf( - SChapter.create().apply { - name = "Chapter" - // page path with a marker at the end - url = "${response.request.url.toString().replace("/gallery/", "/g/")}#" - // number of pages - url += response.asJsoup().select("[id=load_pages]").attr("value") - }, - ) - } - - override fun chapterListSelector() = throw UnsupportedOperationException() - - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() - - // Pages - - override fun fetchPageList(chapter: SChapter): Observable> { - // split the "url" to get the page path and number of pages - return chapter.url.split("#").let { list -> - Observable.just(listOf(1..list[1].toInt()).flatten().map { Page(it, list[0] + "$it/") }) - } - } - - override fun imageUrlParse(document: Document): String { - return document.selectFirst("img#gimg")!!.imgAttr() - } - - override fun pageListParse(document: Document): List = throw UnsupportedOperationException() - - // Filters - - override fun getFilterList() = FilterList( - Filter.Header("NOTE: Ignored if using text search!"), - Filter.Separator(), - GenreFilter(), - ) - - // Top 50 tags - private class GenreFilter : UriPartFilter( - "Category", - arrayOf( - Pair("