diff --git a/src/ru/henchan/build.gradle b/src/ru/henchan/build.gradle new file mode 100644 index 000000000..facc8cdc6 --- /dev/null +++ b/src/ru/henchan/build.gradle @@ -0,0 +1,10 @@ +ext { + extName = 'HenChan' + extClass = '.HenChan' + themePkg = 'multichan' + baseUrl = 'https://xxl.hentaichan.live' + overrideVersionCode = 40 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ru/henchan/res/mipmap-hdpi/ic_launcher.png b/src/ru/henchan/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..eca8d1e50 Binary files /dev/null and b/src/ru/henchan/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ru/henchan/res/mipmap-mdpi/ic_launcher.png b/src/ru/henchan/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..2be6ad46c Binary files /dev/null and b/src/ru/henchan/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ru/henchan/res/mipmap-xhdpi/ic_launcher.png b/src/ru/henchan/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..72bf0892d Binary files /dev/null and b/src/ru/henchan/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ru/henchan/res/mipmap-xxhdpi/ic_launcher.png b/src/ru/henchan/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..7fcff819f Binary files /dev/null and b/src/ru/henchan/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ru/henchan/res/mipmap-xxxhdpi/ic_launcher.png b/src/ru/henchan/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..0cb79374c Binary files /dev/null and b/src/ru/henchan/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ru/henchan/src/eu/kanade/tachiyomi/extension/ru/henchan/HenChan.kt b/src/ru/henchan/src/eu/kanade/tachiyomi/extension/ru/henchan/HenChan.kt new file mode 100644 index 000000000..5a31c1ac1 --- /dev/null +++ b/src/ru/henchan/src/eu/kanade/tachiyomi/extension/ru/henchan/HenChan.kt @@ -0,0 +1,502 @@ +package eu.kanade.tachiyomi.extension.ru.henchan + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.widget.Toast +import androidx.preference.EditTextPreference +import eu.kanade.tachiyomi.multisrc.multichan.MultiChan +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable +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.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.getPreferencesLazy +import okhttp3.Headers +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class HenChan : MultiChan("HenChan", "https://xxl.hentaichan.live", "ru"), ConfigurableSource { + + override val id = 5504588601186153612 + + private val preferences: SharedPreferences by getPreferencesLazy() + + private val domain = preferences.getString(DOMAIN_TITLE, DOMAIN_DEFAULT)!! + + override val baseUrl = domain + + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/manga/newest?offset=${20 * (page - 1)}") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = if (query.isNotEmpty()) { + "$baseUrl/?do=search&subaction=search&story=$query&search_start=$page" + } else { + var genres = "" + var order = "" + filters.forEach { filter -> + when (filter) { + is GenreList -> { + filter.state + .filter { !it.isIgnored() } + .forEach { f -> + genres += (if (f.isExcluded()) "-" else "") + f.id + '+' + } + } + + else -> return@forEach + } + } + + if (genres.isNotEmpty()) { + filters.forEach { filter -> + when (filter) { + is OrderBy -> { + order = filter.toUriPartWithGenres() + } + + else -> return@forEach + } + } + "$baseUrl/tags/${genres.dropLast(1)}&sort=manga$order?offset=${20 * (page - 1)}" + } else { + filters.forEach { filter -> + when (filter) { + is OrderBy -> { + order = filter.toUriPartWithoutGenres() + } + + else -> return@forEach + } + } + "$baseUrl/$order?offset=${20 * (page - 1)}" + } + } + return GET(url, headers) + } + + override fun searchMangaSelector() = ".content_row:not(:has(div.item:containsOwn(Тип)))" + + private fun String.getHQThumbnail(): String { + val isExHenManga = this.contains("/manganew_thumbs_blur/") + val regex = "(?<=/)manganew_thumbs\\w*?(?=/)".toRegex(RegexOption.IGNORE_CASE) + return this.replace(regex, "showfull_retina/manga") + .replace( + "_".plus(URL(baseUrl).host), + "_hentaichan.ru", + ) // domain-related replacing for very old mangas + .plus( + if (isExHenManga) { + "#" + } else { + "" + }, + ) // # for later so we know what type manga is it + } + + override fun popularMangaFromElement(element: Element): SManga { + val manga = super.popularMangaFromElement(element) + manga.thumbnail_url = element.select("img").first()!!.attr("src").getHQThumbnail() + return manga + } + + override fun mangaDetailsParse(document: Document): SManga { + val manga = super.mangaDetailsParse(document) + manga.thumbnail_url = document.select("img#cover").attr("abs:src").getHQThumbnail() + return manga + } + + override fun fetchChapterList(manga: SManga): Observable> { + return client.newCall(chapterListRequest(manga)) + .asObservable().doOnNext { response -> + if (!response.isSuccessful) { + response.close() + // Error message for exceeding last page + if (response.code == 404) { + Observable.just( + listOf( + SChapter.create().apply { + url = manga.url + name = "Chapter" + chapter_number = 1f + }, + ), + ) + } else { + throw Exception("HTTP error ${response.code}") + } + } + } + .map { response -> + chapterListParse(response) + } + } + + override fun chapterListRequest(manga: SManga): Request { + val url = baseUrl + if (manga.thumbnail_url?.endsWith("#") == true) { + manga.url + } else { + manga.url.replace("/manga/", "/related/") + } + return (GET(url, headers)) + } + + override fun chapterListSelector() = ".related" + + override fun chapterListParse(response: Response): List { + val responseUrl = response.request.url.toString() + val document = response.asJsoup() + + // exhentai chapter + if (responseUrl.contains("/manga/")) { + val chap = SChapter.create() + chap.setUrlWithoutDomain(responseUrl) + chap.name = document.select("a.title_top_a").text() + chap.chapter_number = 1F + + val date = document.select("div.row4_right b")?.text()?.let { + SimpleDateFormat("dd MMMM yyyy", Locale("ru")).parse(it)?.time ?: 0 + } ?: 0 + chap.date_upload = date + return listOf(chap) + } + + // one chapter, nothing related + if (document.select("#right > div:nth-child(4)").text().contains(" похожий на ")) { + val chap = SChapter.create() + chap.setUrlWithoutDomain(document.select("#left > div > a").attr("href")) + chap.name = document.select("#right > div:nth-child(4)").text() + .split(" похожий на ")[1] + .replace("\\\"", "\"") + .replace("\\'", "'") + chap.chapter_number = 1F + chap.date_upload = + Date().time // setting to current date because of a sorting in the "Recent updates" section + return listOf(chap) + } + + // has related chapters + val result = mutableListOf() + result.addAll( + document.select(chapterListSelector()).map { + chapterFromElement(it) + }, + ) + + var url = document.select("div#pagination_related a:contains(Вперед)").attr("href") + while (url.isNotBlank()) { + val get = GET( + "${response.request.url}/$url", + headers = headers, + ) + val nextPage = client.newCall(get).execute().asJsoup() + result.addAll( + nextPage.select(chapterListSelector()).map { + chapterFromElement(it) + }, + ) + + url = nextPage.select("div#pagination_related a:contains(Вперед)").attr("href") + } + + return result.reversed() + } + + override fun chapterFromElement(element: Element): SChapter { + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(element.select("h2 a").attr("href")) + val chapterName = element.select("h2 a").attr("title") + chapter.name = chapterName + chapter.chapter_number = + "(глава\\s|часть\\s)([0-9]+\\.?[0-9]*)".toRegex(RegexOption.IGNORE_CASE) + .find(chapterName)?.groupValues?.get(2)?.toFloat() ?: -1F + chapter.date_upload = + Date().time // setting to current date because of a sorting in the "Recent updates" section + return chapter + } + + override fun pageListRequest(chapter: SChapter): Request { + val url = if (chapter.url.contains("/manga/")) { + baseUrl + chapter.url.replace("/manga/", "/online/") + } else { + baseUrl + chapter.url + } + return GET(url, Headers.Builder().add("Accept", "image/webp,image/apng").build()) + } + + override fun pageListParse(response: Response): List { + val html = response.body.string() + val prefix = "fullimg\": [" + val beginIndex = html.indexOf(prefix) + prefix.length + val endIndex = html.indexOf("]", beginIndex) + val trimmedHtml = html.substring(beginIndex, endIndex) + .replace("\"", "") + .replace("\'", "") + + val pageUrls = trimmedHtml.split(", ") + return pageUrls.mapIndexed { i, url -> Page(i, "", url) } + } + + private class Genre( + val id: String, + @SuppressLint("DefaultLocale") name: String = id.replace('_', ' ').capitalize(), + ) : Filter.TriState(name) + + private class GenreList(genres: List) : Filter.Group("Тэги", genres) + private class OrderBy : UriPartFilter( + "Сортировка", + arrayOf("Дата", "Популярность", "Алфавит"), + arrayOf("&n=dateasc" to "", "&n=favasc" to "&n=favdesc", "&n=abcdesc" to "&n=abcasc"), + arrayOf( + "manga/new&n=dateasc" to "manga/new", + "manga/new&n=favasc" to "mostfavorites&sort=manga", + "manga/new&n=abcdesc" to "manga/new&n=abcasc", + ), + ) + + private open class UriPartFilter( + displayName: String, + sortNames: Array, + val withGenres: Array>, + val withoutGenres: Array>, + ) : + Filter.Sort(displayName, sortNames, Selection(1, false)) { + fun toUriPartWithGenres() = + if (state!!.ascending) withGenres[state!!.index].first else withGenres[state!!.index].second + + fun toUriPartWithoutGenres() = + if (state!!.ascending) withoutGenres[state!!.index].first else withoutGenres[state!!.index].second + } + + override fun getFilterList() = FilterList( + OrderBy(), + GenreList(getGenreList()), + ) + + private fun getGenreList() = listOf( + Genre("3D"), + Genre("action"), + Genre("ahegao"), + Genre("bdsm"), + Genre("corruption"), + Genre("foot_fetish"), + Genre("footfuck"), + Genre("gender_bender"), + Genre("live"), + Genre("lolcon"), + Genre("megane"), + Genre("mind_break"), + Genre("monstergirl"), + Genre("netorare"), + Genre("netori"), + Genre("nipple_penetration"), + Genre("oyakodon"), + Genre("paizuri_(titsfuck)"), + Genre("rpg"), + Genre("scat"), + Genre("shemale"), + Genre("shimaidon"), + Genre("shooter"), + Genre("simulation"), + Genre("skinsuit"), + Genre("tomboy"), + Genre("tomgirl"), + Genre("x-ray"), + Genre("алкоголь"), + Genre("анал"), + Genre("андроид"), + Genre("анилингус"), + Genre("анимация"), + Genre("аркада"), + Genre("арт"), + Genre("бабушка"), + Genre("без_текста"), + Genre("без_трусиков"), + Genre("без_цензуры"), + Genre("беременность"), + Genre("бикини"), + Genre("близнецы"), + Genre("боди-арт"), + Genre("больница"), + Genre("большая_грудь"), + Genre("большие_попки"), + Genre("бондаж"), + Genre("буккаке"), + Genre("в_ванной"), + Genre("в_общественном_месте"), + Genre("в_первый_раз"), + Genre("в_цвете"), + Genre("в_школе"), + Genre("вампиры"), + Genre("веб"), + Genre("вебкам"), + Genre("вибратор"), + Genre("визуальная_новелла"), + Genre("внучка"), + Genre("волосатые_женщины"), + Genre("гаремник"), + Genre("гг_девушка"), + Genre("гг_парень"), + Genre("гипноз"), + Genre("глубокий_минет"), + Genre("горячий_источник"), + Genre("грудастая_лоли"), + Genre("групповой_секс"), + Genre("гяру_и_гангуро"), + Genre("двойное_проникновение"), + Genre("девочки_волшебницы"), + Genre("девушка_туалет"), + Genre("демоны"), + Genre("дилдо"), + Genre("дочь"), + Genre("драма"), + Genre("дыра_в_стене"), + Genre("жестокость"), + Genre("за_деньги"), + Genre("зомби"), + Genre("зрелые_женщины"), + Genre("измена"), + Genre("изнасилование"), + Genre("инопланетяне"), + Genre("инцест"), + Genre("исполнение_желаний"), + Genre("камера"), + Genre("квест"), + Genre("кимоно"), + Genre("колготки"), + Genre("комиксы"), + Genre("косплей"), + Genre("кремпай"), + Genre("кудере"), + Genre("кузина"), + Genre("куннилингус"), + Genre("купальники"), + Genre("латекс_и_кожа"), + Genre("магия"), + Genre("маленькая_грудь"), + Genre("мастурбация"), + Genre("мать"), + Genre("мейдочки"), + Genre("мерзкий_дядька"), + Genre("минет"), + Genre("много_девушек"), + Genre("молоко"), + Genre("монашки"), + Genre("монстры"), + Genre("мочеиспускание"), + Genre("мужская_озвучка"), + Genre("мужчина_крепкого_телосложения"), + Genre("мускулистые_женщины"), + Genre("на_природе"), + Genre("наблюдение"), + Genre("непрямой_инцест"), + Genre("новелла"), + Genre("обмен_партнерами"), + Genre("обмен_телами"), + Genre("обычный_секс"), + Genre("огромная_грудь"), + Genre("огромный_член"), + Genre("оплодотворение"), + Genre("остановка_времени"), + Genre("парень_пассив"), + Genre("переодевание"), + Genre("песочница"), + Genre("племянница"), + Genre("пляж"), + Genre("подглядывание"), + Genre("подчинение"), + Genre("похищение"), + Genre("презерватив"), + Genre("принуждение"), + Genre("прозрачная_одежда"), + Genre("проникновение_в_матку"), + Genre("психические_отклонения"), + Genre("публично"), + Genre("рабыни"), + Genre("романтика"), + Genre("сверхъестественное"), + Genre("секс_игрушки"), + Genre("сестра"), + Genre("сетакон"), + Genre("скрытный_секс"), + Genre("спортивная_форма"), + Genre("спящие"), + Genre("страпон"), + Genre("суккубы"), + Genre("темнокожие"), + Genre("тентакли"), + Genre("толстушки"), + Genre("трап"), + Genre("тётя"), + Genre("умеренная_жестокость"), + Genre("учитель_и_ученик"), + Genre("ушастые"), + Genre("фантазии"), + Genre("фантастика"), + Genre("фемдом"), + Genre("фестиваль"), + Genre("фетиш"), + Genre("фистинг"), + Genre("фурри"), + Genre("футанари"), + Genre("футанари_имеет_парня"), + Genre("фэнтези"), + Genre("хоррор"), + Genre("цундере"), + Genre("чикан"), + Genre("чирлидеры"), + Genre("чулки"), + Genre("школьная_форма"), + Genre("школьники"), + Genre("школьницы"), + Genre("школьный_купальник"), + Genre("щекотка"), + Genre("эксгибиционизм"), + Genre("эльфы"), + Genre("эччи"), + Genre("юмор"), + Genre("юри"), + Genre("яндере"), + Genre("яой"), + ) + + override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { + EditTextPreference(screen.context).apply { + key = DOMAIN_TITLE + this.title = DOMAIN_TITLE + summary = domain + this.setDefaultValue(DOMAIN_DEFAULT) + dialogTitle = DOMAIN_TITLE + setOnPreferenceChangeListener { _, newValue -> + try { + val res = + preferences.edit().putString(DOMAIN_TITLE, newValue as String).commit() + Toast.makeText( + screen.context, + "Для смены домена необходимо перезапустить приложение с полной остановкой.", + Toast.LENGTH_LONG, + ).show() + res + } catch (e: Exception) { + e.printStackTrace() + false + } + } + }.let(screen::addPreference) + } + + companion object { + private const val DOMAIN_TITLE = "Домен" + private const val DOMAIN_DEFAULT = "https://xxl.hentaichan.live" + } +}