diff --git a/src/ja/hakusensha/build.gradle.kts b/src/ja/hakusensha/build.gradle.kts new file mode 100644 index 00000000000..f3efce72998 --- /dev/null +++ b/src/ja/hakusensha/build.gradle.kts @@ -0,0 +1,21 @@ +import io.github.keiyoushi.gradle.api.ContentWarning + +plugins { + alias(kei.plugins.extension) +} + +keiyoushi { + name = "Hakusensha e-net" + versionCode = 0 + contentWarning = ContentWarning.MIXED + libVersion = "1.6" + + source { + lang = "ja" + baseUrl = "https://www.hakusensha-e.net" + } +} + +dependencies { + implementation(project(":lib:clipstudioreader")) +} diff --git a/src/ja/hakusensha/res/mipmap-hdpi/ic_launcher.png b/src/ja/hakusensha/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..5a9107d2ab0 Binary files /dev/null and b/src/ja/hakusensha/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ja/hakusensha/res/mipmap-mdpi/ic_launcher.png b/src/ja/hakusensha/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..8d932446892 Binary files /dev/null and b/src/ja/hakusensha/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ja/hakusensha/res/mipmap-xhdpi/ic_launcher.png b/src/ja/hakusensha/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..fda9c879aae Binary files /dev/null and b/src/ja/hakusensha/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ja/hakusensha/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/hakusensha/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..c01e786c2d4 Binary files /dev/null and b/src/ja/hakusensha/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ja/hakusensha/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/hakusensha/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..7aed3d78e2f Binary files /dev/null and b/src/ja/hakusensha/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Dto.kt b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Dto.kt new file mode 100644 index 00000000000..e7a0018b4d7 --- /dev/null +++ b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Dto.kt @@ -0,0 +1,128 @@ +package eu.kanade.tachiyomi.extension.ja.hakusensha + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +@Serializable +class RankingResponse( + val ranking: List, +) + +@Serializable +class Ranking( + val product: Group, +) + +@Serializable +class GroupsResponse( + @JsonNames("products") val groups: List, +) + +@Serializable +class Group( + @SerialName("product_group_name") private val productGroupName: String, + @SerialName("product_group_title") @JsonNames("product_search_title") private val productGroupTitle: String, + @SerialName("cover_image") private val coverImage: String?, + private val type: String, +) { + fun toSManga() = SManga.create().apply { + url = productGroupName + title = productGroupTitle + thumbnail_url = coverImage?.toThumbnail() + memo = buildJsonObject { + put("type", type) + } + } +} + +@Serializable +class ProductsResponse( + val products: List, +) { + fun toSManga() = SManga.create().apply { + val sorted = products.sortedBy { it.productOrder } + val first = sorted.first() + title = first.productGroupTitle + author = first.author?.joinToString { it.authorName } + description = first.introduction + genre = first.category?.joinToString() + thumbnail_url = sorted.last().coverImage?.toThumbnail() + memo = buildJsonObject { + put("type", first.type) + } + } +} + +@Serializable +class Product( + @SerialName("product_name") val productName: String, + @SerialName("product_group_title") val productGroupTitle: String, + @SerialName("product_order") val productOrder: Int?, + @SerialName("product_order_title") private val productOrderTitle: String, + @SerialName("cover_image") val coverImage: String?, + val type: String, + val author: List?, + val introduction: String?, + val category: List?, + @SerialName("release_at") val releaseAt: Long?, + @SerialName("is_free_streaming") private val isFreeStreaming: Boolean?, +) { + fun isLocked(purchased: Set) = isFreeStreaming != true && productName !in purchased + + fun toSChapter(purchased: Set) = SChapter.create().apply { + val lock = if (isLocked(purchased)) "🔒 " else "" + url = productName + name = lock + productOrderTitle + date_upload = releaseAt ?: 0L + chapter_number = productOrder?.toFloat() ?: -1f + memo = buildJsonObject { + val access = when { + productName in purchased -> "bookshelf" + isFreeStreaming == true -> "free" + else -> "locked" + } + put("type", type) + put("access", access) + } + } +} + +@Serializable +class Author( + @SerialName("author_name") val authorName: String, +) + +@Serializable +class PurchasedResponse( + @SerialName("purchased_list") val purchasedList: List, +) + +@Serializable +class AuthToken( + val token: String, + val expire: Long, +) + +@Serializable +class UserAuth( + val userId: String, + val token: String, + val expire: Long, +) + +@Serializable +class KeyResponse( + val bs: Viewer, +) + +@Serializable +class Viewer( + val url: String, +) + +private fun String.toThumbnail() = "https://assets.hakusensha-e.net/$this" diff --git a/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Filters.kt b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Filters.kt new file mode 100644 index 00000000000..8c5fec7ea39 --- /dev/null +++ b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Filters.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.extension.ja.hakusensha + +import eu.kanade.tachiyomi.source.model.Filter + +open class SelectFilter(displayName: String, private val vals: Array>) : Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + val value: String + get() = vals[state].second +} + +class ListFilter : + SelectFilter( + "一覧", + arrayOf( + "新着" to "product?type=new", + "無料" to "product?type=free", + "完結作品" to "group?type=complete", + "完結作品:少女、女性コミック" to "group?type=complete&target=comic_girl_woman", + "完結作品:少年、青年コミック" to "group?type=complete&target=comic_boy_man", + "完結作品:ボーイズラブコミック" to "group?type=complete&target=comic_BL", + "種類:少女、女性コミック" to "group?type=title&target=comic_girl_woman", + "種類:少年、青年コミック" to "group?type=title&target=comic_boy_man", + "種類:ボーイズラブコミック" to "group?type=title&target=comic_BL", + "種類:グラビア写真集" to "group?type=title&target=comic_gravure", + "種類:絵本" to "group?type=title&target=comic_picturebook", + "種類:少女、女性コミック雑誌" to "group?type=title&target=comic_magazine_girl_woman", + "種類:少年、青年コミック雑誌" to "group?type=title&target=comic_magazine_boy_man", + "種類:ボーイズラブコミック雑誌" to "group?type=title&target=comic_magazine_BL", + "掲載誌:花とゆめ" to "group?type=magazine&target=hanatoyume", + "掲載誌:ザ花とゆめ" to "group?type=magazine&target=thehanatoyume", + "掲載誌:花ゆめAi" to "group?type=magazine&target=hanayumeai", + "掲載誌:Trifle by 花とゆめ" to "group?type=magazine&target=triflebyhanatoyume", + "掲載誌:LaLa" to "group?type=magazine&target=lala", + "掲載誌:異世界転生LaLa" to "group?type=magazine&target=isekaitenseilala", + "掲載誌:××LaLa" to "group?type=magazine&target=xxlala", + "掲載誌:LaLaDX" to "group?type=magazine&target=laladx", + "掲載誌:メロディ" to "group?type=magazine&target=melody", + "掲載誌:マンガPark" to "group?type=magazine&target=mangapark", + "掲載誌:黒蜜" to "group?type=magazine&target=kuromitsu", + "掲載誌:楽園" to "group?type=magazine&target=rakuen", + "掲載誌:花丸漫画" to "group?type=magazine&target=hanamarumanga", + "掲載誌:Love Jossie" to "group?type=magazine&target=lovejossie", + "掲載誌:Love Silky" to "group?type=magazine&target=lovesilky", + "掲載誌:ホラー シルキー" to "group?type=magazine&target=horrorsilky", + "掲載誌:ヤングアニマル" to "group?type=magazine&target=younganimal", + "ジャンル:ラブ" to "group?type=category&target=ラブ", + "ジャンル:ファンタジー" to "group?type=category&target=ファンタジー", + "ジャンル:学園" to "group?type=category&target=学園", + "ジャンル:アクション(バトル)" to "group?type=category&target=アクション", + "ジャンル:コメディ(ギャグ・エッセイ)" to "group?type=category&target=コメディ", + "ジャンル:ミステリー(ホラー)" to "group?type=category&target=ミステリー", + "ジャンル:ヒューマンドラマ(禁断)" to "group?type=category&target=ヒューマンドラマ", + "ジャンル:スポーツ・音楽・芸能" to "group?type=category&target=スポーツ・音楽・芸能", + "ジャンル:青春・友情" to "group?type=category&target=青春・友情", + "ジャンル:感動モノ(ハートフル・ファミリー・動物)" to "group?type=category&target=感動モノ", + "ジャンル:時代モノ" to "group?type=category&target=時代モノ", + "ジャンル:SF" to "group?type=category&target=SF", + "ジャンル:グルメ" to "group?type=category&target=グルメ", + "ジャンル:グラビア写真集" to "group?type=category&target=グラビア写真集", + "ジャンル:絵本" to "group?type=category&target=絵本", + "ジャンル:BLコミック" to "group?type=category&target=BLコミック", + "掲載開始年代:1980年代" to "group?type=year&target=1980", + "掲載開始年代:1990年代" to "group?type=year&target=1990", + "掲載開始年代:2000年代" to "group?type=year&target=2000", + "掲載開始年代:2010年代" to "group?type=year&target=2010", + "掲載開始年代:2020年代" to "group?type=year&target=2020", + ), + ) diff --git a/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Hakusensha.kt b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Hakusensha.kt new file mode 100644 index 00000000000..8c388cdd755 --- /dev/null +++ b/src/ja/hakusensha/src/eu/kanade/tachiyomi/extension/ja/hakusensha/Hakusensha.kt @@ -0,0 +1,188 @@ +package eu.kanade.tachiyomi.extension.ja.hakusensha + +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +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.SMangaUpdate +import keiyoushi.annotation.Source +import keiyoushi.lib.clipstudioreader.ClipStudioReaderInterceptor +import keiyoushi.lib.clipstudioreader.fetchPages +import keiyoushi.network.get +import keiyoushi.source.KeiSource +import keiyoushi.utils.firstInstance +import keiyoushi.utils.getLocalStorage +import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.parseAs +import keiyoushi.utils.string +import keiyoushi.utils.toJsonString +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient + +@Source +abstract class Hakusensha : + KeiSource(), + ConfigurableSource { + private val apiUrl get() = "https://product.api.hakusensha-e.net/v1" + private val preferences by getPreferencesLazy() + private val authMutex = Mutex() + private val apiHeaders get() = headersBuilder() + .set("Authorization", GUEST_TOKEN) + .build() + + override fun OkHttpClient.Builder.configureClient() = addInterceptor(ClipStudioReaderInterceptor()) + + override suspend fun getPopularManga(page: Int): MangasPage { + val url = "$apiUrl/ranking/week".toHttpUrl().newBuilder() + .addQueryParameter("platform", "web") + .addQueryParameter("type", "comic") + .addQueryParameter("ts", System.currentTimeMillis().toString()) + .build() + val result = client.get(url, apiHeaders).parseAs() + val mangas = result.ranking.map { it.product.toSManga() }.distinctBy { it.url } + return MangasPage(mangas, false) + } + + override suspend fun getLatestUpdates(page: Int): MangasPage = getSearchMangaList(page, "", FilterList(ListFilter())) + + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { + val listFilter = filters.firstInstance().value + val url = if (query.isNotBlank()) { + "$apiUrl/search/keyword".toHttpUrl().newBuilder() + .addQueryParameter("type", "keyword") + .addQueryParameter("keyword", query) + .build() + } else { + "$apiUrl/search/$listFilter".toHttpUrl() + } + val result = client.get(url, apiHeaders).parseAs() + val mangas = result.groups.map { it.toSManga() }.distinctBy { it.url } + return MangasPage(mangas, false) + } + + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val purchased = async { + val auth = getAuth() ?: return@async emptyList() + val url = "https://order.api.hakusensha-e.net/v1/users/${auth.userId}/purchasedJdcns".toHttpUrl().newBuilder() + .addQueryParameter("title_name", manga.url) + .build() + val response = client.get(url, userHeaders(auth), ensureSuccess = false) + if (response.code == 403) { + response.close() + expireAuth(auth) + return@async emptyList() + } + response.parseAs().purchasedList + } + val products = async { client.get("$apiUrl/products/group/${manga.url}", apiHeaders).parseAs() } + + val hideLocked = preferences.getBoolean(HIDE_LOCKED_PREF_KEY, false) + val result = products.await() + val purchasedSet = purchased.await().toSet() + val now = System.currentTimeMillis() + val chapterList = result.products + .filter { (it.releaseAt ?: 0L) <= now && (!hideLocked || !it.isLocked(purchasedSet)) } + .map { it.toSChapter(purchasedSet) } + .sortedByDescending { it.chapter_number } + + SMangaUpdate( + result.toSManga(), + chapterList, + ) + } + + override fun getMangaUrl(manga: SManga): String = if (manga.memo["type"]!!.string == "koma") { + "$baseUrl/store/koma/product/${manga.url}" + } else { + "$baseUrl/store/group/${manga.url}" + } + + override fun getChapterUrl(chapter: SChapter): String = runBlocking { fetchViewerUrl(chapter) } + + override suspend fun getPageList(chapter: SChapter): List = client.fetchPages(fetchViewerUrl(chapter).toHttpUrl()) + + private suspend fun fetchViewerUrl(chapter: SChapter): String { + val type = chapter.memo["type"]!!.string + if (type.startsWith("novel")) throw Exception("Novels are not supported.") + + val access = chapter.memo["access"]!!.string + val auth = if (access == "bookshelf") getAuth() else null + if (access == "locked" || (access == "bookshelf" && auth == null)) { + throw Exception("Log in via WebView and purchase this chapter to read.") + } + + val keyPath = if (type == "koma") "koma/$access" else access + val url = "https://reader.api.hakusensha-e.net/v1/keys/$keyPath/${chapter.url}".toHttpUrl().newBuilder() + .addQueryParameter("viewer", "bs") + .addQueryParameter("platform", "web") + .apply { if (auth != null) addQueryParameter("user_id", auth.userId) } + .build() + + val newHeaders = if (auth != null) userHeaders(auth) else apiHeaders + val response = client.get(url, newHeaders, ensureSuccess = false) + if (auth != null && response.code == 403) { + response.close() + expireAuth(auth) + throw Exception("Log in via WebView and purchase this chapter to read.") + } + return response.parseAs().bs.url + } + + private suspend fun getAuth(): UserAuth? = authMutex.withLock { + val stored = preferences.getString(AUTH_PREF_KEY, null)?.parseAs() + if (stored != null && stored.expire > System.currentTimeMillis()) return@withLock stored + + val userId = getLocalStorage(baseUrl, "UserId") ?: return@withLock null + val token = getLocalStorage(baseUrl, "AuthToken")?.parseAs() + ?.takeIf { it.expire > System.currentTimeMillis() && it.token != stored?.token } ?: return@withLock null + + UserAuth(userId, token.token, token.expire).also { + preferences.edit().putString(AUTH_PREF_KEY, it.toJsonString()).apply() + } + } + + private fun expireAuth(auth: UserAuth) { + preferences.edit().putString(AUTH_PREF_KEY, UserAuth(auth.userId, auth.token, 0L).toJsonString()).apply() + } + + private fun userHeaders(auth: UserAuth) = headersBuilder() + .set("Authorization", auth.token) + .build() + + override fun getFilterList(data: JsonElement?): FilterList = FilterList( + Filter.Header("Note: Novels are not supported!"), + ListFilter(), + ) + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + SwitchPreferenceCompat(screen.context).apply { + key = HIDE_LOCKED_PREF_KEY + title = "Hide Locked Chapters" + setDefaultValue(false) + }.also(screen::addPreference) + } + + companion object { + private const val HIDE_LOCKED_PREF_KEY = "hide_locked" + private const val AUTH_PREF_KEY = "auth" + + // the site's own fallback guest token, valid until 2030 + private const val GUEST_TOKEN = "a53d742d15704d3ba42ab291994485d0" + } +}