* Add Hachi

* PR suggestions

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
Vetle Ledaal 2024-07-19 07:47:50 +02:00 committed by Draff
parent ec59467da4
commit fe4676497a
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
10 changed files with 610 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.kanade.tachiyomi.extension.en.hachi.HachiUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hachi.moe"
android:pathPattern="/article/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'Hachi'
extClass = '.Hachi'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -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<MangasPage> {
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<ArticleResponseDto>()
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<DetailsResponseDto>()
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<SChapter> {
val dto = response.parseAs<DetailsResponseDto>()
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<Page> {
val dto = response.parseAs<ChapterResponseDto>()
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 <reified T> 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<NextDataDto>(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/(?<slug>[^/]+)(?:/chapter/(?<number>[^/?&#]+))?""".toRegex()
const val SEARCH_PREFIX = "slug:"
}
}

View File

@ -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<TagDto>,
// val totalItems: Int,
// val totalPages: Int,
// ) {
// @Serializable
// class TagDto(
// val articleCount: Int,
// val id: Int,
// val name: String,
// )
// }
@Serializable
class ArticleResponseDto(
val content: List<ArticleDto>,
// 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<ChapterDto>,
// val comicSeries: ComicSeriesDto,
// val metaTags: List<MetaTagDto>,
// val moreLikeArticles: List<ArticleDto>,
// 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<RatingCountDto>,
// 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<LibraryEntryCountDto>? = 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<String>,
// val metaTags: List<MetaTagDto>,
) {
// @Serializable
// class ChapterFullDto(
// val alternativeTitles: List<AlternativeTitleDto>,
// val articleId: Int,
// val articleType: String,
// val articleUrl: String,
// val chapterNumber: Float,
// val createdAt: String,
// val id: Int,
// val imageLinks: List<String>,
// 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<AlternativeTitleDto>,
@Serializable(with = MissingFieldSerializer::class)
val artist: String?,
@Serializable(with = MissingFieldSerializer::class)
val author: String?,
// val chapters: List<ChapterDto>,
val coverImage: String,
// val createdAt: String,
// val externalLinks: List<ExternalLinkDto>,
// 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<String>,
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<String?> {
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")
}
}

View File

@ -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)
}
}