diff --git a/src/en/hachi/AndroidManifest.xml b/src/en/hachi/AndroidManifest.xml new file mode 100644 index 000000000..c5fa43e9f --- /dev/null +++ b/src/en/hachi/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/en/hachi/build.gradle b/src/en/hachi/build.gradle new file mode 100644 index 000000000..5d60461e2 --- /dev/null +++ b/src/en/hachi/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Hachi' + extClass = '.Hachi' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/hachi/res/mipmap-hdpi/ic_launcher.png b/src/en/hachi/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f21175cbb Binary files /dev/null and b/src/en/hachi/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/hachi/res/mipmap-mdpi/ic_launcher.png b/src/en/hachi/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..998f93fad Binary files /dev/null and b/src/en/hachi/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..8c83f7b0c Binary files /dev/null and b/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..261ec505e Binary files /dev/null and b/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b9f36c669 Binary files /dev/null and b/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt new file mode 100644 index 000000000..e1c643e4e --- /dev/null +++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt @@ -0,0 +1,295 @@ +package eu.kanade.tachiyomi.extension.en.hachi + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class Hachi : HttpSource() { + override val baseUrl = "https://hachi.moe" + override val lang = "en" + override val name = "Hachi" + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .addInterceptor(::buildIdOutdatedInterceptor) + .build() + + private fun buildIdOutdatedInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if ( + response.code == 404 && + request.url.run { + host == baseUrl.removePrefix("https://") && + pathSegments.getOrNull(0) == "_next" && + pathSegments.getOrNull(1) == "data" && + fragment != "DO_NOT_RETRY" + } && + response.header("Content-Type")?.contains("text/html") != false + ) { + // The 404 page should have the current buildId + val document = response.asJsoup() + buildId = fetchBuildId(document) + + // Redo request with new buildId + val url = request.url.newBuilder() + .setPathSegment(2, buildId) + .fragment("DO_NOT_RETRY") + .build() + val newRequest = request.newBuilder() + .url(url) + .build() + + return chain.proceed(newRequest) + } + + return response + } + + private val json: Json by injectLazy() + private val apiBaseUrl = "https://api.${baseUrl.toHttpUrl().host}" + + // Popular + override fun popularMangaRequest(page: Int): Request { + val url = "$apiBaseUrl/article".toHttpUrl().newBuilder() + .addQueryParameter("page", (page - 1).toString()) + .addQueryParameter("size", "28") + .addQueryParameter("property", "views") + .addQueryParameter("direction", "desc") + .addQueryParameter("query", "") + .addQueryParameter("fields", "title") + .addQueryParameter("tagMode", "false") + .addQueryParameter("type", "") + .addQueryParameter("status", "") + .addQueryParameter("chapterCount", "4") + .addQueryParameter("mature", "true") + .build() + + return GET(url, headers) + } + + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + // Latest + override fun latestUpdatesRequest(page: Int): Request { + val url = "$apiBaseUrl/article".toHttpUrl().newBuilder() + .addQueryParameter("page", (page - 1).toString()) + .addQueryParameter("size", "28") + .addQueryParameter("property", "latestChapterDate") + .addQueryParameter("direction", "desc") + .addQueryParameter("query", "") + .addQueryParameter("fields", "title") + .addQueryParameter("tagMode", "false") + .addQueryParameter("type", "") + .addQueryParameter("status", "") + .addQueryParameter("chapterCount", "4") + .addQueryParameter("mature", "true") + .build() + + return GET(url, headers) + } + + override fun latestUpdatesParse(response: Response) = searchMangaParse(response) + + // Search + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + if (!query.startsWith(SEARCH_PREFIX)) { + return super.fetchSearchManga(page, query, filters) + } + + val request = mangaDetailsRequest( + SManga.create().apply { + url = "/article/${query.substringAfter(SEARCH_PREFIX)}" + }, + ) + + return client.newCall(request).asObservableSuccess().map { response -> + val details = mangaDetailsParse(response) + MangasPage(listOf(details), false) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$apiBaseUrl/article".toHttpUrl().newBuilder().apply { + addQueryParameter("page", (page - 1).toString()) + addQueryParameter("size", "28") + addQueryParameter("direction", "desc") + addQueryParameter("query", query) + addQueryParameter("fields", "title") + addQueryParameter("tagMode", "false") + addQueryParameter("type", "") + addQueryParameter("status", "") + addQueryParameter("chapterCount", "4") + addQueryParameter("mature", "true") + }.build() + + return GET(url, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val dto = response.parseAs() + val mangas = dto.content.map { manga -> + SManga.create().apply { + setUrlWithoutDomain("/article/${manga.link}") + title = manga.title + artist = manga.artist + author = manga.author + description = manga.summary + genre = manga.tags.joinToString() + status = manga.status.parseStatus() + thumbnail_url = manga.coverImage + initialized = true + } + } + + return MangasPage(mangas, !dto.last) + } + + // Details + override fun mangaDetailsRequest(manga: SManga): Request { + val slug = patternMangaUrl.find(manga.url)?.groups?.get("slug")?.value + ?: throw Exception("Failed to find manga from URL") + + val url = "$baseUrl/_next/data/$buildId/article/$slug.json".toHttpUrl().newBuilder() + .addQueryParameter("url", slug) + .build() + + return GET(url, headers) + } + + override fun getMangaUrl(manga: SManga): String { + return super.mangaDetailsRequest(manga).url.toString() + } + + override fun mangaDetailsParse(response: Response): SManga { + val dto = response.parseAs() + + return SManga.create().apply { + url = "$baseUrl/article/${dto.pageProps.article.link}" + title = dto.pageProps.article.title + artist = dto.pageProps.article.artist + author = dto.pageProps.article.author + description = dto.pageProps.article.summary + genre = dto.pageProps.article.tags.joinToString() + status = dto.pageProps.article.status.parseStatus() + thumbnail_url = dto.pageProps.article.coverImage + initialized = true + } + } + + // Chapters + override fun chapterListRequest(manga: SManga): Request { + return mangaDetailsRequest(manga) + } + + override fun chapterListParse(response: Response): List { + val dto = response.parseAs() + val chapters = dto.pageProps.chapters.map { chapter -> + SChapter.create().apply { + val chapterNumber = chapter.chapterNumber.toString().removeSuffix(".0") + setUrlWithoutDomain("/article/${dto.pageProps.article.link}/chapter/$chapterNumber") + name = "Chapter $chapterNumber" + + date_upload = runCatching { + dateFormat.parse(chapter.createdAt)?.time + }.getOrNull() ?: 0 + chapter_number = chapter.chapterNumber + } + } + + return chapters + } + + // Pages + override fun pageListRequest(chapter: SChapter): Request { + val matchGroups = patternMangaUrl.find(chapter.url)!!.groups + val slug = matchGroups["slug"]!!.value + val number = matchGroups["number"]!!.value + + val url = "$baseUrl/_next/data/$buildId/article/$slug/chapter/$number.json".toHttpUrl() + .newBuilder() + .addQueryParameter("url", slug) + .addQueryParameter("number", number) + .build() + + return GET(url, headers) + } + + override fun getChapterUrl(chapter: SChapter): String { + return super.pageListRequest(chapter).url.toString() + } + + override fun pageListParse(response: Response): List { + val dto = response.parseAs() + + return dto.pageProps.images.mapIndexed { i, img -> + Page(i, response.request.url.toString(), img) + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + // Other + private inline fun Response.parseAs(): T = + json.decodeFromString(body.string()) + + private fun String.parseStatus() = when (this.lowercase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + "dropped" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + private fun fetchBuildId(document: Document? = null): String { + val realDocument = document + ?: client.newCall(GET(baseUrl, headers)).execute().use { it.asJsoup() } + + val nextData = realDocument.selectFirst("script#__NEXT_DATA__")?.data() + ?: throw Exception("Failed to find __NEXT_DATA__") + + val dto = json.decodeFromString(nextData) + return dto.buildId + } + + private var buildId = "" + get() { + if (field == "") { + field = fetchBuildId() + } + return field + } + + companion object { + private val dateFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val patternMangaUrl = + """/article/(?[^/]+)(?:/chapter/(?[^/?&#]+))?""".toRegex() + const val SEARCH_PREFIX = "slug:" + } +} diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt new file mode 100644 index 000000000..5ec348572 --- /dev/null +++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt @@ -0,0 +1,249 @@ +package eu.kanade.tachiyomi.extension.en.hachi + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +// Responses +// @Serializable +// class TagResponseDto( +// val currentPage: Int, +// val size: Int, +// val tags: List, +// val totalItems: Int, +// val totalPages: Int, +// ) { +// @Serializable +// class TagDto( +// val articleCount: Int, +// val id: Int, +// val name: String, +// ) +// } + +@Serializable +class ArticleResponseDto( + val content: List, +// val empty: Boolean, +// val first: Boolean, + val last: Boolean, +// val number: Int, +// val numberOfElements: Int, +// val pageable: PageableDto, +// val size: Int, +// val sort: SortDto, +// val totalElements: Int, +// val totalPages: Int, +) { +// @Serializable +// class PageableDto( +// val offset: Int, +// val pageNumber: Int, +// val pageSize: Int, +// val paged: Boolean, +// val sort: SortDto, +// val unpaged: Boolean, +// ) +} + +@Serializable +class DetailsResponseDto( +// @SerialName("__N_SSG") +// val nSSG: Boolean, + val pageProps: PagePropsDto, +) { + @Serializable + class PagePropsDto( + val article: ArticleDto, + val chapters: List, +// val comicSeries: ComicSeriesDto, +// val metaTags: List, +// val moreLikeArticles: List, +// val ratings: RatingsDto, +// val stats: StatsDto, +// val title: String, + ) { +// @Serializable +// class ComicSeriesDto( +// val alternativeHeadline: String, +// val artist: ArtistDto, +// val author: AuthorDto, +// @SerialName("@context") +// val context: String, +// val genre: String, +// val name: String, +// @SerialName("@type") +// val type: String, +// val url: String, +// ) { +// @Serializable +// class ArtistDto( +// val name: String, +// @SerialName("@type") +// val type: String, +// ) +// +// @Serializable +// class AuthorDto( +// val name: String, +// @SerialName("@type") +// val type: String, +// ) +// } +// +// @Serializable +// class RatingsDto( +// val averageRating: Float, +// val id: Int, +// val ratingCounts: List, +// val totalRatingCount: Int, +// ) { +// @Serializable +// class RatingCountDto( +// val count: Int, +// val rating: Float, +// ) +// } +// +// @Serializable +// class StatsDto( +// val allTimeViews: Int? = null, +// val id: Int? = null, +// val libraryEntryCounts: List? = null, +// val monthlyViews: Int? = null, +// val rank: Int? = null, +// val totalLibraryEntries: Int? = null, +// val weeklyViews: Int? = null, +// ) { +// @Serializable +// class LibraryEntryCountDto( +// val count: Int, +// val status: String, +// ) +// } + } +} + +@Serializable +class ChapterResponseDto( +// @SerialName("__N_SSG") +// val nSSG: Boolean, + val pageProps: PagePropsDto, +) { + @Serializable + class PagePropsDto( +// val chapter: ChapterFullDto, + val images: List, +// val metaTags: List, + ) { +// @Serializable +// class ChapterFullDto( +// val alternativeTitles: List, +// val articleId: Int, +// val articleType: String, +// val articleUrl: String, +// val chapterNumber: Float, +// val createdAt: String, +// val id: Int, +// val imageLinks: List, +// val mature: Boolean, +// val nextChapterNumber: Float, +// val previousChapterNumber: Float, +// val title: String, +// val totalChapters: Float, +// ) + } +} + +// Common +@Serializable +class ChapterDto( + val chapterNumber: Float, + val createdAt: String, +// val id: Int, +// val views: Int, +) + +// @Serializable +// class MetaTagDto( +// val content: String, +// val `property`: String, +// ) + +// @Serializable +// class AlternativeTitleDto( +// val language: String, +// val title: String, +// ) + +// @Serializable +// class SortDto( +// val empty: Boolean, +// val sorted: Boolean, +// val unsorted: Boolean, +// ) + +// @Serializable +// class ExternalLinkDto( +// val externalApp: ExternalAppDto, +// val externalId: String, +// val id: Int, +// ) { +// @Serializable +// class ExternalAppDto( +// val domain: String, +// val id: Int, +// val name: String, +// val path: String, +// ) +// } + +@Serializable +class ArticleDto( +// val alternativeTitles: List, + @Serializable(with = MissingFieldSerializer::class) + val artist: String?, + @Serializable(with = MissingFieldSerializer::class) + val author: String?, +// val chapters: List, + val coverImage: String, +// val createdAt: String, +// val externalLinks: List, +// val id: Int, +// val imagePath: String? = null, + val link: String, +// val maintainer: String? = null, +// val mature: Boolean, +// val originalLink: String? = null, +// val poster: String? = null, +// val rating: Float, + val status: String, + val summary: String, + val tags: List, + val title: String, +// val totalChapters: Float, +// val type: String, +// val updatedAt: String, +// val views: Int, +) + +// Partial +@Serializable +class NextDataDto( + val buildId: String, +) + +// Serializers +object MissingFieldSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("MissingField") + + override fun deserialize(decoder: Decoder): String? { + return decoder.decodeString().takeIf { it != "N/A" } + } + + override fun serialize(encoder: Encoder, value: String?) { + encoder.encodeString(value ?: "N/A") + } +} diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt new file mode 100644 index 000000000..77549d451 --- /dev/null +++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.en.hachi + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class HachiUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + + if (pathSegments != null && pathSegments.size > 1) { + val slug = pathSegments[1] + + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${Hachi.SEARCH_PREFIX}$slug") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("HachiUrlActivity", e.toString()) + } + } else { + Log.e("HachiUrlActivity", "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}