diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 87bd3a768..bda8001d6 100755 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -23,6 +23,7 @@ + diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaLoaderContext.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaLoaderContext.kt index 6d9c92ed3..1dbf9de34 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaLoaderContext.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaLoaderContext.kt @@ -12,10 +12,13 @@ open class MangaLoaderContext( private val cookieJar: CookieJar ) : KoinComponent { - suspend fun httpGet(url: String): Response { + suspend fun httpGet(url: String, headers: Headers? = null): Response { val request = Request.Builder() .get() .url(url) + if (headers != null) { + request.headers(headers) + } return okHttp.newCall(request.build()).await() } diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index ba9ab7851..fe514b6d8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -3,11 +3,11 @@ package org.koitharu.kotatsu.browser.cloudflare import android.graphics.Bitmap import android.webkit.WebView import okhttp3.HttpUrl.Companion.toHttpUrl -import org.koitharu.kotatsu.core.network.CookieJar +import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.WebViewClientCompat class CloudFlareClient( - private val cookieJar: CookieJar, + private val cookieJar: AndroidCookieJar, private val callback: CloudFlareCallback, private val targetUrl: String ) : WebViewClientCompat() { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt index 9d35e9df5..41476a63b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -31,7 +31,15 @@ enum class MangaSource( MANGAREAD("MangaRead", "en", MangareadRepository::class.java), REMANGA("Remanga", "ru", RemangaRepository::class.java), HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java), - ANIBEL("Anibel", "be", AnibelRepository::class.java); + ANIBEL("Anibel", "be", AnibelRepository::class.java), + NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java), + NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java), + NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java), + NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java), + NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java), + NINEMANGA_BR("NineManga Brasil", "br", NineMangaRepository.Brazil::class.java), + NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java), + ; @get:Throws(NoBeanDefFoundException::class) @Deprecated("") diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt similarity index 90% rename from app/src/main/java/org/koitharu/kotatsu/core/network/CookieJar.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt index 4740e8b13..fb806bda1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CookieJar.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt @@ -7,7 +7,7 @@ import okhttp3.HttpUrl import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class CookieJar : CookieJar { +class AndroidCookieJar : CookieJar { private val cookieManager = CookieManager.getInstance() @@ -28,10 +28,6 @@ class CookieJar : CookieJar { } } - fun clearAsync() { - cookieManager.removeAllCookies(null) - } - suspend fun clear() = suspendCoroutine { continuation -> cookieManager.removeAllCookies(continuation::resume) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt new file mode 100644 index 000000000..b3fa833cd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.core.network + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.io.IOException +import java.nio.charset.StandardCharsets + +class CurlLoggingInterceptor( + private val extraCurlOptions: String? = null, +) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + var compressed = false + val curlCmd = StringBuilder("curl") + if (extraCurlOptions != null) { + curlCmd.append(" ").append(extraCurlOptions) + } + curlCmd.append(" -X ").append(request.method) + val headers = request.headers + var i = 0 + val count = headers.size + while (i < count) { + val name = headers.name(i) + val value = headers.value(i) + if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value, + ignoreCase = true) + ) { + compressed = true + } + curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"") + i++ + } + val requestBody = request.body + if (requestBody != null) { + val buffer = Buffer() + requestBody.writeTo(buffer) + val contentType = requestBody.contentType() + val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 + curlCmd.append(" --data $'") + .append(buffer.readString(charset).replace("\n", "\\n")) + .append("'") + } + curlCmd.append(if (compressed) " --compressed " else " ").append(request.url) + Log.d(TAG, "╭--- cURL (" + request.url + ")") + Log.d(TAG, curlCmd.toString()) + Log.d(TAG, "╰--- (copy and paste the above line to a terminal)") + return chain.proceed(request) + } + + private companion object { + + const val TAG = "CURL" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt index 6691dd50c..4d5c26d3b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -6,12 +6,13 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.utils.CacheUtils import java.util.concurrent.TimeUnit val networkModule get() = module { - single { CookieJar() } bind CookieJar::class + single { AndroidCookieJar() } bind CookieJar::class single(named(CacheUtils.QUALIFIER_HTTP)) { CacheUtils.createHttpCache(androidContext()) } single { OkHttpClient.Builder().apply { @@ -22,6 +23,9 @@ val networkModule cache(get(named(CacheUtils.QUALIFIER_HTTP))) addInterceptor(UserAgentInterceptor()) addInterceptor(CloudFlareInterceptor()) + if (BuildConfig.DEBUG) { + addNetworkInterceptor(CurlLoggingInterceptor()) + } }.build() } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt index 09ae0c43c..baf3156e3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt @@ -25,4 +25,11 @@ val parserModule factory(named(MangaSource.REMANGA)) { RemangaRepository(get()) } factory(named(MangaSource.HENTAILIB)) { HentaiLibRepository(get()) } factory(named(MangaSource.ANIBEL)) { AnibelRepository(get()) } + factory(named(MangaSource.NINEMANGA_EN)) { NineMangaRepository.English(get()) } + factory(named(MangaSource.NINEMANGA_BR)) { NineMangaRepository.Brazil(get()) } + factory(named(MangaSource.NINEMANGA_DE)) { NineMangaRepository.Deutsch(get()) } + factory(named(MangaSource.NINEMANGA_ES)) { NineMangaRepository.Spanish(get()) } + factory(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) } + factory(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) } + factory(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt new file mode 100644 index 000000000..aca8baca3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt @@ -0,0 +1,201 @@ +package org.koitharu.kotatsu.core.parser.site + +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.base.domain.MangaLoaderContext +import org.koitharu.kotatsu.core.exceptions.ParseException +import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.ext.* +import java.util.* + +abstract class NineMangaRepository( + loaderContext: MangaLoaderContext, + override val source: MangaSource, + override val defaultDomain: String, +) : RemoteMangaRepository(loaderContext) { + + init { + loaderContext.insertCookies(getDomain(), "ninemanga_template_desk=yes") + } + + override val sortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + ) + + override suspend fun getList( + offset: Int, + query: String?, + sortOrder: SortOrder?, + tag: MangaTag?, + ): List { + val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1 + val url = buildString { + append("https://") + append(getDomain()) + if (query.isNullOrEmpty()) { + append("/category/") + if (tag != null) { + append(tag.key) + } else { + append("index") + } + append("_") + append(page) + append(".html") + } else { + append("/search/?name_sel=&wd=") + append(query.urlEncoded()) + append("&page=") + append(page) + append(".html") + } + } + val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml() + val root = doc.body().selectFirst("ul.direlist") + ?: throw ParseException("Cannot find root") + val baseHost = root.baseUri().toHttpUrl().host + return root.select("li").map { node -> + val href = node.selectFirst("a").absUrl("href") + val relUrl = href.toRelativeUrl(baseHost) + val dd = node.selectFirst("dd") + Manga( + id = generateUid(relUrl), + url = relUrl, + publicUrl = href, + title = dd.selectFirst("a.bookname").text().toCamelCase(), + altTitle = null, + coverUrl = node.selectFirst("img").absUrl("src"), + rating = Manga.NO_RATING, + author = null, + tags = emptySet(), + state = null, + source = source, + description = dd.selectFirst("p").html(), + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = loaderContext.httpGet( + manga.url.withDomain() + "?waring=1", + PREDEFINED_HEADERS + ).parseHtml() + val root = doc.body().selectFirst("div.manga") + ?: throw ParseException("Cannot find root") + val infoRoot = root.selectFirst("div.bookintro") + ?: throw ParseException("Cannot find info") + return manga.copy( + tags = infoRoot.getElementsByAttributeValue("itemprop", "genre")?.first() + ?.select("a")?.mapToSet { a -> + MangaTag( + title = a.text(), + key = a.attr("href").substringBetween("/", "."), + source = source, + ) + }.orEmpty(), + author = infoRoot.getElementsByAttributeValue("itemprop", "author")?.first()?.text(), + description = infoRoot.getElementsByAttributeValue("itemprop", "description")?.first() + ?.html()?.substringAfter(""), + chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul") + ?.select("li")?.asReversed()?.mapIndexed { i, li -> + val a = li.selectFirst("a") + val href = a.relUrl("href") + MangaChapter( + id = generateUid(href), + name = a.text(), + number = i + 1, + url = href, + branch = null, + source = source, + ) + } + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = loaderContext.httpGet(chapter.url.withDomain(), PREDEFINED_HEADERS).parseHtml() + return doc.body().getElementById("page")?.select("option")?.map { option -> + val url = option.attr("value") + MangaPage( + id = generateUid(url), + url = url, + referer = chapter.url.withDomain(), + preview = null, + source = source, + ) + } ?: throw ParseException("Pages list not found at ${chapter.url}") + } + + override suspend fun getPageUrl(page: MangaPage): String { + val doc = loaderContext.httpGet(page.url.withDomain(), PREDEFINED_HEADERS).parseHtml() + val root = doc.body() + return root.selectFirst("a.pic_download")?.absUrl("href") + ?: throw ParseException("Page image not found") + } + + override suspend fun getTags(): Set { + val doc = loaderContext.httpGet("https://${getDomain()}/category/", PREDEFINED_HEADERS) + .parseHtml() + val root = doc.body().selectFirst("ul.genreidex") + return root.select("li").mapToSet { li -> + val a = li.selectFirst("a") + MangaTag( + title = a.text(), + key = a.attr("href").substringBetweenLast("/", "."), + source = source + ) + } + } + + class English(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_EN, + "www.ninemanga.com", + ) + + class Spanish(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_ES, + "es.ninemanga.com", + ) + + class Russian(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_RU, + "ru.ninemanga.com", + ) + + class Deutsch(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_DE, + "de.ninemanga.com", + ) + + class Brazil(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_BR, + "br.ninemanga.com", + ) + + class Italiano(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_IT, + "it.ninemanga.com", + ) + + class Francais(loaderContext: MangaLoaderContext) : NineMangaRepository( + loaderContext, + MangaSource.NINEMANGA_FR, + "fr.ninemanga.com", + ) + + private companion object { + + const val PAGE_SIZE = 26 + + val PREDEFINED_HEADERS = Headers.Builder() + .add("Accept-Language", "en-US;q=0.7,en;q=0.3") + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index cf380cffe..dfff7115f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -11,7 +11,7 @@ import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.network.CookieJar +import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.Cache import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider @@ -75,7 +75,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } AppSettings.KEY_COOKIES_CLEAR -> { viewLifecycleScope.launch { - val cookieJar = get() + val cookieJar = get() cookieJar.clear() Snackbar.make( listView ?: return@launch, diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index 05ec233ae..4cfa1c808 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -29,6 +29,25 @@ fun String.removeSurrounding(vararg chars: Char): String { return this } +fun String.toCamelCase(): String { + if (isEmpty()) { + return this + } + val result = StringBuilder(length) + var capitalize = true + for (char in this) { + result.append( + if (capitalize) { + char.uppercase() + } else { + char.lowercase() + } + ) + capitalize = char.isWhitespace() + } + return result.toString() +} + fun String.transliterate(skipMissing: Boolean): String { val cyr = charArrayOf( 'a', 'б', 'в', 'г', 'д', 'ё', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', @@ -92,7 +111,7 @@ fun String.md5(): String { .padStart(32, '0') } -fun String.substringBetween(from: String, to: String, fallbackValue: String): String { +fun String.substringBetween(from: String, to: String, fallbackValue: String = this): String { val fromIndex = indexOf(from) if (fromIndex == -1) { return fallbackValue @@ -105,6 +124,19 @@ fun String.substringBetween(from: String, to: String, fallbackValue: String): St } } +fun String.substringBetweenLast(from: String, to: String, fallbackValue: String = this): String { + val fromIndex = lastIndexOf(from) + if (fromIndex == -1) { + return fallbackValue + } + val toIndex = lastIndexOf(to) + return if (toIndex == -1) { + fallbackValue + } else { + substring(fromIndex + from.length, toIndex) + } +} + fun String.find(regex: Regex) = regex.find(this)?.value fun String.levenshteinDistance(other: String): Int { diff --git a/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt b/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt index c04ae731d..09bdf00ed 100644 --- a/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt +++ b/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt @@ -10,7 +10,7 @@ class TemporaryCookieJar : CookieJar { override fun loadForRequest(url: HttpUrl): List { val time = System.currentTimeMillis() - return cache.values.filter { it.matches(url) && it.expiresAt < time } + return cache.values.filter { it.matches(url) && it.expiresAt >= time } } override fun saveFromResponse(url: HttpUrl, cookies: List) {