diff --git a/src/zh/terrahistoricus/AndroidManifest.xml b/src/zh/terrahistoricus/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/zh/terrahistoricus/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/zh/terrahistoricus/build.gradle b/src/zh/terrahistoricus/build.gradle
new file mode 100644
index 000000000..13b0dd776
--- /dev/null
+++ b/src/zh/terrahistoricus/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Terra Historicus'
+ pkgNameSuffix = 'zh.terrahistoricus'
+ extClass = '.TerraHistoricus'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/zh/terrahistoricus/res/mipmap-hdpi/ic_launcher.png b/src/zh/terrahistoricus/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..2327339be
Binary files /dev/null and b/src/zh/terrahistoricus/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/terrahistoricus/res/mipmap-mdpi/ic_launcher.png b/src/zh/terrahistoricus/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..e01962dd3
Binary files /dev/null and b/src/zh/terrahistoricus/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/terrahistoricus/res/mipmap-xhdpi/ic_launcher.png b/src/zh/terrahistoricus/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..7cbda0126
Binary files /dev/null and b/src/zh/terrahistoricus/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/terrahistoricus/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/terrahistoricus/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..0a7408218
Binary files /dev/null and b/src/zh/terrahistoricus/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/terrahistoricus/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/terrahistoricus/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..3bd9c2bd3
Binary files /dev/null and b/src/zh/terrahistoricus/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/terrahistoricus/res/web_hi_res_512.png b/src/zh/terrahistoricus/res/web_hi_res_512.png
new file mode 100644
index 000000000..8540a8891
Binary files /dev/null and b/src/zh/terrahistoricus/res/web_hi_res_512.png differ
diff --git a/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricus.kt b/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricus.kt
new file mode 100644
index 000000000..bf2f8aebe
--- /dev/null
+++ b/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricus.kt
@@ -0,0 +1,63 @@
+package eu.kanade.tachiyomi.extension.zh.terrahistoricus
+
+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.online.HttpSource
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class TerraHistoricus : HttpSource() {
+ override val name = "泰拉记事社"
+ override val lang = "zh"
+ override val baseUrl = "https://terra-historicus.hypergryph.com"
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/comic")
+
+ override fun popularMangaParse(response: Response) = MangasPage(
+ json.decodeFromString>>(response.body!!.string()).data.map(THComic::toSManga),
+ false
+ )
+
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/recentUpdate")
+
+ override fun latestUpdatesParse(response: Response) = MangasPage(
+ json.decodeFromString>>(response.body!!.string()).data.map(THRecentUpdate::toSManga),
+ false
+ )
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
+ throw UnsupportedOperationException("没有搜索功能")
+
+ override fun searchMangaParse(response: Response) = throw UnsupportedOperationException("没有搜索功能")
+
+ override fun mangaDetailsParse(response: Response) =
+ json.decodeFromString>(response.body!!.string()).data.toSManga()
+
+ override fun chapterListParse(response: Response) =
+ json.decodeFromString>(response.body!!.string()).data.toSChapterList().orEmpty()
+
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ return client.newCall(pageListRequest(chapter))
+ .asObservableSuccess()
+ .map { response ->
+ (0 until json.decodeFromString>(response.body!!.string()).data.pageInfos!!.size).map {
+ Page(it, "$baseUrl${chapter.url}/page?pageNum=${it + 1}")
+ }
+ }
+ }
+
+ override fun pageListParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ override fun imageUrlParse(response: Response) =
+ json.decodeFromString>(response.body!!.string()).data.url
+}
diff --git a/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricusDto.kt b/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricusDto.kt
new file mode 100644
index 000000000..85be93a25
--- /dev/null
+++ b/src/zh/terrahistoricus/src/eu/kanade/tachiyomi/extension/zh/terrahistoricus/TerraHistoricusDto.kt
@@ -0,0 +1,93 @@
+package eu.kanade.tachiyomi.extension.zh.terrahistoricus
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class THResult(val code: Int, val msg: String, val data: T)
+
+@Serializable
+data class THComic(
+ val cid: String,
+// val type: Int,
+ val cover: String,
+ val title: String,
+ val subtitle: String,
+ val authors: List,
+ val keywords: List? = null,
+ val introduction: String? = null,
+// val direction: String? = null,
+ val episodes: List? = null,
+ val updateTime: Long? = null, // timestamp in seconds
+) {
+ private fun getDescription(): String? {
+ var result = ""
+ if (subtitle.isNotEmpty()) result += "「$subtitle」"
+ if (introduction != null) result += introduction
+ return result.ifEmpty { null }
+ }
+
+ fun toSManga() = SManga.create().apply {
+ url = "/api/comic/$cid"
+ title = this@THComic.title
+ author = authors.joinToString("、")
+ thumbnail_url = cover
+ description = getDescription()
+ genre = keywords?.joinToString(",")?.replace(",", ", ")
+ }
+
+ fun toSChapterList() = episodes?.map { episode ->
+ SChapter.create().apply {
+ url = "/api/comic/$cid/episode/${episode.cid!!}"
+ try {
+ chapter_number = episode.shortTitle?.toFloat() ?: chapter_number
+ name = episode.title
+ } catch (e: NumberFormatException) {
+ name = "${episode.shortTitle} ${episode.title}"
+ }
+ date_upload = (updateTime ?: 0L) * 1000
+ }
+ }
+}
+
+@Serializable
+data class THRecentUpdate(
+ val coverUrl: String,
+ val comicCid: String,
+ val title: String,
+// val subtitle: String,
+// val episodeCid: String,
+// val episodeType: Int,
+// val episodeShortTitle: String,
+// val updateTime: Long
+) {
+ fun toSManga() = SManga.create().apply {
+ url = "/api/comic/$comicCid"
+ title = this@THRecentUpdate.title
+ thumbnail_url = coverUrl
+ }
+}
+
+@Serializable
+data class THEpisode(
+ val cid: String? = null,
+// val type: Int,
+ val shortTitle: String?,
+ val title: String, // 作品信息中
+// val likes: Int? = null,
+ val pageInfos: List? = null, // 章节详情中
+)
+
+@Serializable
+data class THPageInfo(
+// val width: Int,
+// val height: Int,
+ val doublePage: Boolean,
+)
+
+@Serializable
+data class THPage(
+// val pageNum: Int,
+ val url: String,
+)