Rename
This commit is contained in:
1 parent
b86d90021c
commit
6c0757c91b
972 files changed
+6885
-7321
No files matched your search
@@ -0,0 +1,370 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.constant.IntentAction
|
||||
import io.legato.kazusa.constant.Status
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.help.book.ContentProcessor
|
||||
import io.legato.kazusa.help.book.getBookSource
|
||||
import io.legato.kazusa.help.book.readSimulating
|
||||
import io.legato.kazusa.help.book.simulatedTotalChapterNum
|
||||
import io.legato.kazusa.help.book.update
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.service.AudioPlayService
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import io.legato.kazusa.utils.startService
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import splitties.init.appCtx
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Suppress("unused")
|
||||
object AudioPlay : CoroutineScope by MainScope() {
|
||||
var status = Status.STOP
|
||||
private var activityContext: Context? = null
|
||||
private var serviceContext: Context? = null
|
||||
private val context: Context get() = activityContext ?: serviceContext ?: appCtx
|
||||
var callback: CallBack? = null
|
||||
var book: Book? = null
|
||||
var chapterSize = 0
|
||||
var simulatedChapterSize = 0
|
||||
var durChapterIndex = 0
|
||||
var durChapterPos = 0
|
||||
var durChapter: BookChapter? = null
|
||||
var durPlayUrl = ""
|
||||
var durAudioSize = 0
|
||||
var inBookshelf = false
|
||||
var bookSource: BookSource? = null
|
||||
val loadingChapters = arrayListOf<Int>()
|
||||
|
||||
fun upData(book: Book) {
|
||||
AudioPlay.book = book
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
if (durChapterIndex != book.durChapterIndex) {
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
durPlayUrl = ""
|
||||
durAudioSize = 0
|
||||
}
|
||||
upDurChapter()
|
||||
}
|
||||
|
||||
fun resetData(book: Book) {
|
||||
stop()
|
||||
AudioPlay.book = book
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
bookSource = book.getBookSource()
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
durPlayUrl = ""
|
||||
durAudioSize = 0
|
||||
upDurChapter()
|
||||
postEvent(EventBus.AUDIO_BUFFER_PROGRESS, 0)
|
||||
}
|
||||
|
||||
private fun addLoading(index: Int): Boolean {
|
||||
synchronized(this) {
|
||||
if (loadingChapters.contains(index)) return false
|
||||
loadingChapters.add(index)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLoading(index: Int) {
|
||||
synchronized(this) {
|
||||
loadingChapters.remove(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOrUpPlayUrl() {
|
||||
if (durPlayUrl.isEmpty()) {
|
||||
loadPlayUrl()
|
||||
} else {
|
||||
upPlayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载播放URL
|
||||
*/
|
||||
private fun loadPlayUrl() {
|
||||
val index = durChapterIndex
|
||||
if (addLoading(index)) {
|
||||
val book = book
|
||||
val bookSource = bookSource
|
||||
if (book != null && bookSource != null) {
|
||||
upDurChapter()
|
||||
val chapter = durChapter
|
||||
if (chapter == null) {
|
||||
removeLoading(index)
|
||||
return
|
||||
}
|
||||
upLoading(true)
|
||||
WebBook.getContent(this, bookSource, book, chapter)
|
||||
.onSuccess { content ->
|
||||
if (content.isEmpty()) {
|
||||
appCtx.toastOnUi("未获取到资源链接")
|
||||
} else {
|
||||
contentLoadFinish(chapter, content)
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("获取资源链接出错\n$it", it, true)
|
||||
upLoading(false)
|
||||
}.onFinally {
|
||||
removeLoading(index)
|
||||
}
|
||||
} else {
|
||||
removeLoading(index)
|
||||
appCtx.toastOnUi("book or source is null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完成
|
||||
*/
|
||||
private fun contentLoadFinish(chapter: BookChapter, content: String) {
|
||||
if (chapter.index == book?.durChapterIndex) {
|
||||
durPlayUrl = content
|
||||
upPlayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upPlayUrl() {
|
||||
if (isPlayToEnd()) {
|
||||
playNew()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放当前章节
|
||||
*/
|
||||
fun play() {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.play
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从头播放新章节
|
||||
*/
|
||||
private fun playNew() {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.playNew
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前章节
|
||||
*/
|
||||
fun upDurChapter() {
|
||||
val book = book ?: return
|
||||
durChapter = appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)
|
||||
durAudioSize = durChapter?.end?.toInt() ?: 0
|
||||
postEvent(EventBus.AUDIO_SUB_TITLE, durChapter?.title ?: appCtx.getString(R.string.data_loading))
|
||||
postEvent(EventBus.AUDIO_SIZE, durAudioSize)
|
||||
postEvent(EventBus.AUDIO_PROGRESS, durChapterPos)
|
||||
}
|
||||
|
||||
fun pause(context: Context) {
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.pause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(context: Context) {
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.resume
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustSpeed(adjust: Float) {
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.adjustSpeed
|
||||
putExtra("adjust", adjust)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustProgress(position: Int) {
|
||||
durChapterPos = position
|
||||
saveRead()
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.adjustProgress
|
||||
putExtra("position", position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun skipTo(index: Int) {
|
||||
Coroutine.async {
|
||||
stopPlay()
|
||||
durChapterIndex = index
|
||||
durChapterPos = 0
|
||||
durPlayUrl = ""
|
||||
saveRead()
|
||||
loadPlayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
fun prev() {
|
||||
Coroutine.async {
|
||||
stopPlay()
|
||||
if (durChapterIndex > 0) {
|
||||
durChapterIndex -= 1
|
||||
durChapterPos = 0
|
||||
durPlayUrl = ""
|
||||
saveRead()
|
||||
loadPlayUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun next() {
|
||||
stopPlay()
|
||||
if (durChapterIndex + 1 < simulatedChapterSize) {
|
||||
durChapterIndex += 1
|
||||
durChapterPos = 0
|
||||
durPlayUrl = ""
|
||||
saveRead()
|
||||
loadPlayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimer(minute: Int) {
|
||||
if (AudioPlayService.isRun) {
|
||||
val intent = Intent(context, AudioPlayService::class.java)
|
||||
intent.action = IntentAction.setTimer
|
||||
intent.putExtra("minute", minute)
|
||||
context.startService(intent)
|
||||
} else {
|
||||
AudioPlayService.timeMinute = minute
|
||||
postEvent(EventBus.AUDIO_DS, minute)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTimer() {
|
||||
val intent = Intent(context, AudioPlayService::class.java)
|
||||
intent.action = IntentAction.addTimer
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun stopPlay() {
|
||||
if (AudioPlayService.isRun) {
|
||||
context.startService<AudioPlayService> {
|
||||
action = IntentAction.stopPlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveRead() {
|
||||
val book = book ?: return
|
||||
Coroutine.async {
|
||||
book.lastCheckCount = 0
|
||||
book.durChapterTime = System.currentTimeMillis()
|
||||
val chapterChanged = book.durChapterIndex != durChapterIndex
|
||||
book.durChapterIndex = durChapterIndex
|
||||
book.durChapterPos = durChapterPos
|
||||
if (chapterChanged) {
|
||||
appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex)?.let {
|
||||
book.durChapterTitle = it.getDisplayTitle(
|
||||
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
|
||||
book.getUseReplaceRule()
|
||||
)
|
||||
}
|
||||
}
|
||||
book.update()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存章节长度
|
||||
*/
|
||||
fun saveDurChapter(audioSize: Long) {
|
||||
val chapter = durChapter ?: return
|
||||
Coroutine.async {
|
||||
durAudioSize = audioSize.toInt()
|
||||
chapter.end = audioSize
|
||||
appDb.bookChapterDao.update(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
fun playPositionChanged(position: Int) {
|
||||
durChapterPos = position
|
||||
saveRead()
|
||||
}
|
||||
|
||||
fun upLoading(loading: Boolean) {
|
||||
callback?.upLoading(loading)
|
||||
}
|
||||
|
||||
private fun isPlayToEnd(): Boolean {
|
||||
return durChapterIndex + 1 == simulatedChapterSize
|
||||
&& durChapterPos == durAudioSize
|
||||
}
|
||||
|
||||
fun register(context: Context) {
|
||||
activityContext = context
|
||||
callback = context as CallBack
|
||||
}
|
||||
|
||||
fun unregister(context: Context) {
|
||||
if (activityContext === context) {
|
||||
activityContext = null
|
||||
callback = null
|
||||
}
|
||||
coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
fun registerService(context: Context) {
|
||||
serviceContext = context
|
||||
}
|
||||
|
||||
fun unregisterService() {
|
||||
serviceContext = null
|
||||
}
|
||||
|
||||
interface CallBack {
|
||||
|
||||
fun upLoading(loading: Boolean)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.Keep
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.DefaultData
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.glide.BlurTransformation
|
||||
import io.legato.kazusa.help.glide.ImageLoader
|
||||
import io.legato.kazusa.help.glide.OkHttpModelLoader
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.BitmapUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getPrefBoolean
|
||||
import io.legato.kazusa.utils.getPrefString
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Keep
|
||||
object BookCover {
|
||||
|
||||
private const val coverRuleConfigKey = "legadoCoverRuleConfig"
|
||||
var drawBookName = true
|
||||
private set
|
||||
var drawBookAuthor = true
|
||||
private set
|
||||
lateinit var defaultDrawable: Drawable
|
||||
private set
|
||||
|
||||
|
||||
init {
|
||||
upDefaultCover()
|
||||
}
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
fun upDefaultCover() {
|
||||
val isNightTheme = AppConfig.isNightTheme
|
||||
drawBookName = if (isNightTheme) {
|
||||
appCtx.getPrefBoolean(PreferKey.coverShowNameN, true)
|
||||
} else {
|
||||
appCtx.getPrefBoolean(PreferKey.coverShowName, true)
|
||||
}
|
||||
drawBookAuthor = if (isNightTheme) {
|
||||
appCtx.getPrefBoolean(PreferKey.coverShowAuthorN, true)
|
||||
} else {
|
||||
appCtx.getPrefBoolean(PreferKey.coverShowAuthor, true)
|
||||
}
|
||||
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
|
||||
val path = appCtx.getPrefString(key)
|
||||
if (path.isNullOrBlank()) {
|
||||
defaultDrawable = appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
||||
return
|
||||
}
|
||||
defaultDrawable = kotlin.runCatching {
|
||||
BitmapDrawable(appCtx.resources, BitmapUtils.decodeBitmap(path, 600, 900))
|
||||
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载封面
|
||||
*/
|
||||
fun load(
|
||||
context: Context,
|
||||
path: String?,
|
||||
loadOnlyWifi: Boolean = false,
|
||||
sourceOrigin: String? = null,
|
||||
onLoadFinish: (() -> Unit)? = null
|
||||
): RequestBuilder<Drawable> {
|
||||
if (AppConfig.useDefaultCover) {
|
||||
return ImageLoader.load(context, defaultDrawable)
|
||||
.centerCrop()
|
||||
}
|
||||
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
|
||||
if (sourceOrigin != null) {
|
||||
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
|
||||
}
|
||||
var builder = ImageLoader.load(context, path)
|
||||
.apply(options)
|
||||
if (onLoadFinish != null) {
|
||||
builder = builder.addListener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable?>,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onLoadFinish.invoke()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable?>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onLoadFinish.invoke()
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
return builder.placeholder(defaultDrawable)
|
||||
.error(defaultDrawable)
|
||||
.centerCrop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载漫画图片
|
||||
*/
|
||||
fun loadManga(
|
||||
context: Context,
|
||||
path: String?,
|
||||
loadOnlyWifi: Boolean = false,
|
||||
sourceOrigin: String? = null,
|
||||
): RequestBuilder<Drawable> {
|
||||
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
|
||||
.set(OkHttpModelLoader.mangaOption, true)
|
||||
if (sourceOrigin != null) {
|
||||
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
|
||||
}
|
||||
return ImageLoader.load(context, path)
|
||||
.apply(options)
|
||||
.override(context.resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.skipMemoryCache(true)
|
||||
}
|
||||
|
||||
fun preloadManga(
|
||||
context: Context,
|
||||
path: String?,
|
||||
loadOnlyWifi: Boolean = false,
|
||||
sourceOrigin: String? = null,
|
||||
): RequestBuilder<File?> {
|
||||
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
|
||||
.set(OkHttpModelLoader.mangaOption, true)
|
||||
if (sourceOrigin != null) {
|
||||
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
|
||||
}
|
||||
return Glide.with(context)
|
||||
.downloadOnly()
|
||||
.apply(options)
|
||||
.load(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模糊封面
|
||||
*/
|
||||
fun loadBlur(
|
||||
context: Context,
|
||||
path: String?,
|
||||
loadOnlyWifi: Boolean = false,
|
||||
sourceOrigin: String? = null,
|
||||
): RequestBuilder<Drawable> {
|
||||
val loadBlur = ImageLoader.load(context, defaultDrawable)
|
||||
.transform(BlurTransformation(25), CenterCrop())
|
||||
if (AppConfig.useDefaultCover) {
|
||||
return loadBlur
|
||||
}
|
||||
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
|
||||
if (sourceOrigin != null) {
|
||||
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
|
||||
}
|
||||
return ImageLoader.load(context, path)
|
||||
.apply(options)
|
||||
.transform(BlurTransformation(25), CenterCrop())
|
||||
.transition(DrawableTransitionOptions.withCrossFade(1500))
|
||||
.thumbnail(loadBlur)
|
||||
}
|
||||
|
||||
fun getCoverRule(): CoverRule {
|
||||
return GSON.fromJsonObject<CoverRule>(CacheManager.get(coverRuleConfigKey))
|
||||
.getOrNull()
|
||||
?: DefaultData.coverRule
|
||||
}
|
||||
|
||||
suspend fun searchCover(book: Book): String? {
|
||||
val config = getCoverRule()
|
||||
if (!config.enable || config.searchUrl.isBlank() || config.coverRule.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
config.searchUrl,
|
||||
book.name,
|
||||
source = config,
|
||||
coroutineContext = coroutineContext,
|
||||
hasLoginHeader = false
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait()
|
||||
val analyzeRule = AnalyzeRule(book)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
analyzeRule.setContent(res.body)
|
||||
analyzeRule.setRedirectUrl(res.url)
|
||||
return analyzeRule.getString(config.coverRule, isUrl = true)
|
||||
}
|
||||
|
||||
fun saveCoverRule(config: CoverRule) {
|
||||
val json = GSON.toJson(config)
|
||||
CacheManager.put(coverRuleConfigKey, json)
|
||||
}
|
||||
|
||||
fun delCoverRule() {
|
||||
CacheManager.delete(coverRuleConfigKey)
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class CoverRule(
|
||||
var enable: Boolean = true,
|
||||
var searchUrl: String,
|
||||
var coverRule: String,
|
||||
override var concurrentRate: String? = null,
|
||||
override var loginUrl: String? = null,
|
||||
override var loginUi: String? = null,
|
||||
override var header: String? = null,
|
||||
override var jsLib: String? = null,
|
||||
override var enabledCookieJar: Boolean? = false,
|
||||
) : BaseSource {
|
||||
|
||||
override fun getTag(): String {
|
||||
return searchUrl
|
||||
}
|
||||
|
||||
override fun getKey(): String {
|
||||
return searchUrl
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.content.Context
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.constant.IntentAction
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.exception.ConcurrentException
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.isLocal
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.service.CacheBookService
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import io.legato.kazusa.utils.startService
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object CacheBook {
|
||||
|
||||
val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreate(bookUrl: String): CacheBookModel? {
|
||||
val book = appDb.bookDao.getBook(bookUrl) ?: return null
|
||||
val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
|
||||
updateBookSource(bookSource)
|
||||
var cacheBook = cacheBookMap[bookUrl]
|
||||
if (cacheBook != null) {
|
||||
//存在时更新,书源可能会变化,必须更新
|
||||
cacheBook.bookSource = bookSource
|
||||
cacheBook.book = book
|
||||
return cacheBook
|
||||
}
|
||||
cacheBook = CacheBookModel(bookSource, book)
|
||||
cacheBookMap[bookUrl] = cacheBook
|
||||
return cacheBook
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel {
|
||||
updateBookSource(bookSource)
|
||||
var cacheBook = cacheBookMap[book.bookUrl]
|
||||
if (cacheBook != null) {
|
||||
//存在时更新,书源可能会变化,必须更新
|
||||
cacheBook.bookSource = bookSource
|
||||
cacheBook.book = book
|
||||
return cacheBook
|
||||
}
|
||||
cacheBook = CacheBookModel(bookSource, book)
|
||||
cacheBookMap[book.bookUrl] = cacheBook
|
||||
return cacheBook
|
||||
}
|
||||
|
||||
private fun updateBookSource(newBookSource: BookSource) {
|
||||
cacheBookMap.forEach {
|
||||
val model = it.value
|
||||
if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) {
|
||||
model.bookSource = newBookSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: Context, book: Book, start: Int, end: Int) {
|
||||
if (!book.isLocal) {
|
||||
context.startService<CacheBookService> {
|
||||
action = IntentAction.start
|
||||
putExtra("bookUrl", book.bookUrl)
|
||||
putExtra("start", start)
|
||||
putExtra("end", end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(context: Context, bookUrl: String) {
|
||||
context.startService<CacheBookService> {
|
||||
action = IntentAction.remove
|
||||
putExtra("bookUrl", bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
if (CacheBookService.isRun) {
|
||||
context.startService<CacheBookService> {
|
||||
action = IntentAction.stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
successDownloadSet.clear()
|
||||
errorDownloadMap.clear()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
cacheBookMap.forEach { it.value.stop() }
|
||||
cacheBookMap.clear()
|
||||
clear()
|
||||
}
|
||||
|
||||
val downloadSummary: String
|
||||
get() {
|
||||
return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}"
|
||||
}
|
||||
|
||||
val isRun: Boolean
|
||||
get() {
|
||||
var isRun = false
|
||||
cacheBookMap.forEach {
|
||||
isRun = isRun || it.value.isRun()
|
||||
}
|
||||
return isRun
|
||||
}
|
||||
|
||||
private val waitCount: Int
|
||||
get() {
|
||||
var count = 0
|
||||
cacheBookMap.forEach {
|
||||
count += it.value.waitCount
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
val onDownloadCount: Int
|
||||
get() {
|
||||
var count = 0
|
||||
cacheBookMap.forEach {
|
||||
count += it.value.onDownloadCount
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
val successDownloadSet = linkedSetOf<String>()
|
||||
val errorDownloadMap = hashMapOf<String, Int>()
|
||||
|
||||
class CacheBookModel(var bookSource: BookSource, var book: Book) {
|
||||
|
||||
private val waitDownloadSet = linkedSetOf<Int>()
|
||||
private val onDownloadSet = linkedSetOf<Int>()
|
||||
private var isStopped = false
|
||||
private var waitingRetry = false
|
||||
|
||||
val waitCount get() = waitDownloadSet.size
|
||||
val onDownloadCount get() = onDownloadSet.size
|
||||
|
||||
init {
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isRun(): Boolean {
|
||||
return waitDownloadSet.size > 0 || onDownloadSet.size > 0
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isStop(): Boolean {
|
||||
return isStopped || (!isRun() && !waitingRetry)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
waitDownloadSet.clear()
|
||||
isStopped = true
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDownload(start: Int, end: Int) {
|
||||
isStopped = false
|
||||
for (i in start..end) {
|
||||
if (!onDownloadSet.contains(i)) {
|
||||
waitDownloadSet.add(i)
|
||||
}
|
||||
}
|
||||
cacheBookMap[book.bookUrl] = this
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onSuccess(chapter: BookChapter) {
|
||||
onDownloadSet.remove(chapter.index)
|
||||
successDownloadSet.add(chapter.primaryStr())
|
||||
errorDownloadMap.remove(chapter.primaryStr())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onPreError(chapter: BookChapter, error: Throwable) {
|
||||
waitingRetry = true
|
||||
if (error !is ConcurrentException) {
|
||||
errorDownloadMap[chapter.primaryStr()] =
|
||||
(errorDownloadMap[chapter.primaryStr()] ?: 0) + 1
|
||||
}
|
||||
onDownloadSet.remove(chapter.index)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onPostError(chapter: BookChapter, error: Throwable) {
|
||||
//重试3次
|
||||
if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) {
|
||||
waitDownloadSet.add(chapter.index)
|
||||
} else {
|
||||
AppLog.put(
|
||||
"下载${book.name}-${chapter.title}失败\n${error.localizedMessage}",
|
||||
error
|
||||
)
|
||||
}
|
||||
waitingRetry = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onError(chapter: BookChapter, error: Throwable) {
|
||||
onPreError(chapter, error)
|
||||
onPostError(chapter, error)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onCancel(index: Int) {
|
||||
onDownloadSet.remove(index)
|
||||
if (!isStopped) waitDownloadSet.add(index)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun onFinally() {
|
||||
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
|
||||
cacheBookMap.remove(book.bookUrl)
|
||||
}
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从待下载列表内取第一条下载
|
||||
*/
|
||||
@Synchronized
|
||||
fun download(scope: CoroutineScope, context: CoroutineContext) {
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
val chapterIndex = waitDownloadSet.firstOrNull()
|
||||
if (chapterIndex == null) {
|
||||
if (onDownloadSet.isEmpty()) {
|
||||
cacheBookMap.remove(book.bookUrl)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (onDownloadSet.contains(chapterIndex)) {
|
||||
waitDownloadSet.remove(chapterIndex)
|
||||
return
|
||||
}
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let {
|
||||
waitDownloadSet.remove(chapterIndex)
|
||||
return
|
||||
}
|
||||
if (chapter.isVolume) {
|
||||
/** 修正下载计数 */
|
||||
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
|
||||
waitDownloadSet.remove(chapterIndex)
|
||||
return
|
||||
}
|
||||
if (BookHelp.hasImageContent(book, chapter)) {
|
||||
waitDownloadSet.remove(chapterIndex)
|
||||
return
|
||||
}
|
||||
waitDownloadSet.remove(chapterIndex)
|
||||
onDownloadSet.add(chapterIndex)
|
||||
if (BookHelp.hasContent(book, chapter)) {
|
||||
Coroutine.async(scope, context, executeContext = context) {
|
||||
BookHelp.getContent(book, chapter)?.let {
|
||||
BookHelp.saveImages(bookSource, book, chapter, it, 1)
|
||||
}
|
||||
}.onSuccess {
|
||||
onSuccess(chapter)
|
||||
}.onError {
|
||||
onPreError(chapter, it)
|
||||
//出现错误等待一秒后重新加入待下载列表
|
||||
delay(1000)
|
||||
onPostError(chapter, it)
|
||||
}.onCancel {
|
||||
onCancel(chapterIndex)
|
||||
}.onFinally {
|
||||
onFinally()
|
||||
}
|
||||
return
|
||||
}
|
||||
WebBook.getContent(
|
||||
scope,
|
||||
bookSource,
|
||||
book,
|
||||
chapter,
|
||||
context = context,
|
||||
start = CoroutineStart.LAZY,
|
||||
executeContext = context
|
||||
).onSuccess { content ->
|
||||
onSuccess(chapter)
|
||||
downloadFinish(chapter, content)
|
||||
}.onError {
|
||||
onPreError(chapter, it)
|
||||
//出现错误等待一秒后重新加入待下载列表
|
||||
delay(1000)
|
||||
onPostError(chapter, it)
|
||||
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}")
|
||||
}.onCancel {
|
||||
onCancel(chapterIndex)
|
||||
}.onFinally {
|
||||
onFinally()
|
||||
}.start()
|
||||
}
|
||||
|
||||
suspend fun downloadAwait(chapter: BookChapter): String {
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
synchronized(this) {
|
||||
onDownloadSet.add(chapter.index)
|
||||
waitDownloadSet.remove(chapter.index)
|
||||
}
|
||||
try {
|
||||
val content = WebBook.getContentAwait(bookSource, book, chapter)
|
||||
onSuccess(chapter)
|
||||
ReadBook.downloadedChapters.add(chapter.index)
|
||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||
return content
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
onCancel(chapter.index)
|
||||
}
|
||||
onError(chapter, e)
|
||||
ReadBook.downloadFailChapters[chapter.index] =
|
||||
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
|
||||
return "获取正文失败\n${e.localizedMessage}"
|
||||
} finally {
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun download(
|
||||
scope: CoroutineScope,
|
||||
chapter: BookChapter,
|
||||
semaphore: Semaphore?,
|
||||
resetPageOffset: Boolean = false
|
||||
) {
|
||||
if (onDownloadSet.contains(chapter.index)) {
|
||||
return
|
||||
}
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
onDownloadSet.add(chapter.index)
|
||||
waitDownloadSet.remove(chapter.index)
|
||||
WebBook.getContent(
|
||||
scope,
|
||||
bookSource,
|
||||
book,
|
||||
chapter,
|
||||
start = CoroutineStart.LAZY,
|
||||
executeContext = IO,
|
||||
semaphore = semaphore
|
||||
).onSuccess { content ->
|
||||
onSuccess(chapter)
|
||||
ReadBook.downloadedChapters.add(chapter.index)
|
||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||
downloadFinish(chapter, content, resetPageOffset)
|
||||
}.onError {
|
||||
onError(chapter, it)
|
||||
ReadBook.downloadFailChapters[chapter.index] =
|
||||
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
|
||||
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
|
||||
}.onCancel {
|
||||
onCancel(chapter.index)
|
||||
downloadFinish(chapter, "download canceled", resetPageOffset, true)
|
||||
}.onFinally {
|
||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun downloadFinish(
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
resetPageOffset: Boolean = false,
|
||||
canceled: Boolean = false
|
||||
) {
|
||||
if (ReadBook.book?.bookUrl == book.bookUrl) {
|
||||
ReadBook.contentLoadFinish(
|
||||
book, chapter, content,
|
||||
resetPageOffset = resetPageOffset,
|
||||
canceled = canceled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.content.Context
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.IntentAction
|
||||
import io.legato.kazusa.data.entities.BookSourcePart
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.IntentData
|
||||
import io.legato.kazusa.service.CheckSourceService
|
||||
import io.legato.kazusa.utils.startService
|
||||
import splitties.init.appCtx
|
||||
|
||||
object CheckSource {
|
||||
var keyword = "我的"
|
||||
|
||||
//校验设置
|
||||
var timeout = CacheManager.getLong("checkSourceTimeout") ?: 180000L
|
||||
var checkSearch = CacheManager.get("checkSearch")?.toBoolean() ?: true
|
||||
var checkDiscovery = CacheManager.get("checkDiscovery")?.toBoolean() ?: true
|
||||
var checkInfo = CacheManager.get("checkInfo")?.toBoolean() ?: true
|
||||
var checkCategory = CacheManager.get("checkCategory")?.toBoolean() ?: true
|
||||
var checkContent = CacheManager.get("checkContent")?.toBoolean() ?: true
|
||||
val summary get() = upSummary()
|
||||
|
||||
fun start(context: Context, sources: List<BookSourcePart>) {
|
||||
val selectedIds = sources.map {
|
||||
it.bookSourceUrl
|
||||
}
|
||||
IntentData.put("checkSourceSelectedIds", selectedIds)
|
||||
context.startService<CheckSourceService> {
|
||||
action = IntentAction.start
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService<CheckSourceService> {
|
||||
action = IntentAction.stop
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(context: Context) {
|
||||
context.startService<CheckSourceService> {
|
||||
action = IntentAction.resume
|
||||
}
|
||||
}
|
||||
|
||||
fun putConfig() {
|
||||
CacheManager.put("checkSourceTimeout", timeout)
|
||||
CacheManager.put("checkSearch", checkSearch)
|
||||
CacheManager.put("checkDiscovery", checkDiscovery)
|
||||
CacheManager.put("checkInfo", checkInfo)
|
||||
CacheManager.put("checkCategory", checkCategory)
|
||||
CacheManager.put("checkContent", checkContent)
|
||||
}
|
||||
|
||||
private fun upSummary(): String {
|
||||
var checkItem = ""
|
||||
if (checkSearch) checkItem = "$checkItem ${appCtx.getString(R.string.search)}"
|
||||
if (checkDiscovery) checkItem = "$checkItem ${appCtx.getString(R.string.discovery)}"
|
||||
if (checkInfo) checkItem = "$checkItem ${appCtx.getString(R.string.source_tab_info)}"
|
||||
if (checkCategory) checkItem = "$checkItem ${appCtx.getString(R.string.chapter_list)}"
|
||||
if (checkContent) checkItem = "$checkItem ${appCtx.getString(R.string.main_body)}"
|
||||
return appCtx.getString(
|
||||
R.string.check_source_config_summary,
|
||||
(timeout / 1000).toString(),
|
||||
checkItem
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import io.legato.kazusa.BuildConfig
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.data.entities.*
|
||||
import io.legato.kazusa.help.book.isWebFile
|
||||
import io.legato.kazusa.help.coroutine.CompositeCoroutine
|
||||
import io.legato.kazusa.help.source.sortUrls
|
||||
import io.legato.kazusa.model.rss.Rss
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.isAbsUrl
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object Debug {
|
||||
var callback: Callback? = null
|
||||
private var debugSource: String? = null
|
||||
private val tasks: CompositeCoroutine = CompositeCoroutine()
|
||||
val debugMessageMap = HashMap<String, String>()
|
||||
private val debugTimeMap = HashMap<String, Long>()
|
||||
var isChecking: Boolean = false
|
||||
|
||||
@SuppressLint("ConstantLocale")
|
||||
private val debugTimeFormat = SimpleDateFormat("[mm:ss.SSS]", Locale.getDefault())
|
||||
private var startTime: Long = System.currentTimeMillis()
|
||||
|
||||
@Synchronized
|
||||
fun log(
|
||||
sourceUrl: String?,
|
||||
msg: String = "",
|
||||
print: Boolean = true,
|
||||
isHtml: Boolean = false,
|
||||
showTime: Boolean = true,
|
||||
state: Int = 1
|
||||
) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("sourceDebug", msg)
|
||||
}
|
||||
//调试信息始终要执行
|
||||
callback?.let {
|
||||
if ((debugSource != sourceUrl || !print)) return
|
||||
var printMsg = msg
|
||||
if (isHtml) {
|
||||
printMsg = HtmlFormatter.format(msg)
|
||||
}
|
||||
if (showTime) {
|
||||
val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime))
|
||||
printMsg = "$time $printMsg"
|
||||
}
|
||||
it.printLog(state, printMsg)
|
||||
}
|
||||
if (isChecking && sourceUrl != null && (msg).length < 30) {
|
||||
var printMsg = msg
|
||||
if (isHtml) {
|
||||
printMsg = HtmlFormatter.format(msg)
|
||||
}
|
||||
if (showTime && debugTimeMap[sourceUrl] != null) {
|
||||
val time =
|
||||
debugTimeFormat.format(Date(System.currentTimeMillis() - debugTimeMap[sourceUrl]!!))
|
||||
printMsg = printMsg.replace(AppPattern.debugMessageSymbolRegex, "")
|
||||
|
||||
debugMessageMap[sourceUrl] = "$time $printMsg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun log(msg: String?) {
|
||||
log(debugSource, msg ?: "", true)
|
||||
}
|
||||
|
||||
fun cancelDebug(destroy: Boolean = false) {
|
||||
tasks.clear()
|
||||
|
||||
if (destroy) {
|
||||
debugSource = null
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
|
||||
fun startChecking(source: BookSource) {
|
||||
isChecking = true
|
||||
debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis()
|
||||
debugMessageMap[source.bookSourceUrl] = "${debugTimeFormat.format(Date(0))} 开始校验"
|
||||
}
|
||||
|
||||
fun finishChecking() {
|
||||
isChecking = false
|
||||
}
|
||||
|
||||
fun getRespondTime(sourceUrl: String): Long {
|
||||
return debugTimeMap[sourceUrl] ?: CheckSource.timeout
|
||||
}
|
||||
|
||||
fun updateFinalMessage(sourceUrl: String, state: String) {
|
||||
if (debugTimeMap[sourceUrl] != null && debugMessageMap[sourceUrl] != null) {
|
||||
val spendingTime = System.currentTimeMillis() - debugTimeMap[sourceUrl]!!
|
||||
debugTimeMap[sourceUrl] =
|
||||
if (state == "校验成功") spendingTime else CheckSource.timeout + spendingTime
|
||||
val printTime = debugTimeFormat.format(Date(spendingTime))
|
||||
debugMessageMap[sourceUrl] = "$printTime $state"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource) {
|
||||
cancelDebug()
|
||||
debugSource = rssSource.sourceUrl
|
||||
log(debugSource, "︾开始解析")
|
||||
val sort = rssSource.sortUrls().first()
|
||||
Rss.getArticles(scope, sort.first, sort.second, rssSource, 1)
|
||||
.onSuccess {
|
||||
if (it.first.isEmpty()) {
|
||||
log(debugSource, "⇒列表页解析成功,为空")
|
||||
log(debugSource, "︽解析完成", state = 1000)
|
||||
} else {
|
||||
val ruleContent = rssSource.ruleContent
|
||||
if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) {
|
||||
log(debugSource, "︽列表页解析完成")
|
||||
log(debugSource, showTime = false)
|
||||
if (ruleContent.isNullOrEmpty()) {
|
||||
log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
|
||||
} else {
|
||||
rssContentDebug(scope, it.first[0], ruleContent, rssSource)
|
||||
}
|
||||
} else {
|
||||
log(debugSource, "⇒存在描述规则,不解析内容页")
|
||||
log(debugSource, "︽解析完成", state = 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssContentDebug(
|
||||
scope: CoroutineScope,
|
||||
rssArticle: RssArticle,
|
||||
ruleContent: String,
|
||||
rssSource: RssSource
|
||||
) {
|
||||
log(debugSource, "︾开始解析内容页")
|
||||
Rss.getContent(scope, rssArticle, ruleContent, rssSource)
|
||||
.onSuccess {
|
||||
log(debugSource, it)
|
||||
log(debugSource, "︽内容页解析完成", state = 1000)
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) {
|
||||
cancelDebug()
|
||||
debugSource = bookSource.bookSourceUrl
|
||||
startTime = System.currentTimeMillis()
|
||||
when {
|
||||
key.isAbsUrl() -> {
|
||||
val book = Book()
|
||||
book.origin = bookSource.bookSourceUrl
|
||||
book.bookUrl = key
|
||||
log(bookSource.bookSourceUrl, "⇒开始访问详情页:$key")
|
||||
infoDebug(scope, bookSource, book)
|
||||
}
|
||||
|
||||
key.contains("::") -> {
|
||||
val url = key.substringAfter("::")
|
||||
log(bookSource.bookSourceUrl, "⇒开始访问发现页:$url")
|
||||
exploreDebug(scope, bookSource, url)
|
||||
}
|
||||
|
||||
key.startsWith("++") -> {
|
||||
val url = key.substring(2)
|
||||
val book = Book()
|
||||
book.origin = bookSource.bookSourceUrl
|
||||
book.tocUrl = url
|
||||
log(bookSource.bookSourceUrl, "⇒开始访目录页:$url")
|
||||
tocDebug(scope, bookSource, book)
|
||||
}
|
||||
|
||||
key.startsWith("--") -> {
|
||||
val url = key.substring(2)
|
||||
val book = Book()
|
||||
book.origin = bookSource.bookSourceUrl
|
||||
log(bookSource.bookSourceUrl, "⇒开始访正文页:$url")
|
||||
val chapter = BookChapter()
|
||||
chapter.title = "调试"
|
||||
chapter.url = url
|
||||
contentDebug(scope, bookSource, book, chapter, null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(bookSource.bookSourceUrl, "⇒开始搜索关键字:$key")
|
||||
searchDebug(scope, bookSource, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) {
|
||||
log(debugSource, "︾开始解析发现页")
|
||||
val explore = WebBook.exploreBook(scope, bookSource, url, 1)
|
||||
.onSuccess { exploreBooks ->
|
||||
if (exploreBooks.isNotEmpty()) {
|
||||
log(debugSource, "︽发现页解析完成")
|
||||
log(debugSource, showTime = false)
|
||||
infoDebug(scope, bookSource, exploreBooks[0].toBook())
|
||||
} else {
|
||||
log(debugSource, "︽未获取到书籍", state = -1)
|
||||
}
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
tasks.add(explore)
|
||||
}
|
||||
|
||||
private fun searchDebug(scope: CoroutineScope, bookSource: BookSource, key: String) {
|
||||
log(debugSource, "︾开始解析搜索页")
|
||||
val search = WebBook.searchBook(scope, bookSource, key, 1)
|
||||
.onSuccess { searchBooks ->
|
||||
if (searchBooks.isNotEmpty()) {
|
||||
log(debugSource, "︽搜索页解析完成")
|
||||
log(debugSource, showTime = false)
|
||||
infoDebug(scope, bookSource, searchBooks[0].toBook())
|
||||
} else {
|
||||
log(debugSource, "︽未获取到书籍", state = -1)
|
||||
}
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
tasks.add(search)
|
||||
}
|
||||
|
||||
private fun infoDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) {
|
||||
if (book.tocUrl.isNotBlank()) {
|
||||
log(debugSource, "≡已获取目录链接,跳过详情页")
|
||||
log(debugSource, showTime = false)
|
||||
tocDebug(scope, bookSource, book)
|
||||
return
|
||||
}
|
||||
log(debugSource, "︾开始解析详情页")
|
||||
val info = WebBook.getBookInfo(scope, bookSource, book)
|
||||
.onSuccess {
|
||||
log(debugSource, "︽详情页解析完成")
|
||||
log(debugSource, showTime = false)
|
||||
if (!book.isWebFile) {
|
||||
tocDebug(scope, bookSource, book)
|
||||
} else {
|
||||
log(debugSource, "≡文件类书源跳过解析目录", state = 1000)
|
||||
}
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
tasks.add(info)
|
||||
}
|
||||
|
||||
private fun tocDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) {
|
||||
log(debugSource, "︾开始解析目录页")
|
||||
val chapterList = WebBook.getChapterList(scope, bookSource, book)
|
||||
.onSuccess { chapters ->
|
||||
log(debugSource, "︽目录页解析完成")
|
||||
log(debugSource, showTime = false)
|
||||
val toc = chapters.filter { !(it.isVolume && it.url.startsWith(it.title)) }
|
||||
if (toc.isEmpty()) {
|
||||
log(debugSource, "≡没有正文章节")
|
||||
return@onSuccess
|
||||
}
|
||||
val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
|
||||
contentDebug(scope, bookSource, book, toc.first(), nextChapterUrl)
|
||||
}
|
||||
.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
tasks.add(chapterList)
|
||||
}
|
||||
|
||||
private fun contentDebug(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
nextChapterUrl: String?
|
||||
) {
|
||||
log(debugSource, "︾开始解析正文页")
|
||||
val content = WebBook.getContent(
|
||||
scope = scope,
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
bookChapter = bookChapter,
|
||||
nextChapterUrl = nextChapterUrl,
|
||||
needSave = false
|
||||
).onSuccess {
|
||||
log(debugSource, "︽正文页解析完成", state = 1000)
|
||||
}.onError {
|
||||
log(debugSource, it.stackTraceStr, state = -1)
|
||||
}
|
||||
tasks.add(content)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun printLog(state: Int, msg: String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.content.Context
|
||||
import io.legato.kazusa.constant.IntentAction
|
||||
import io.legato.kazusa.service.DownloadService
|
||||
import io.legato.kazusa.utils.startService
|
||||
|
||||
object Download {
|
||||
|
||||
|
||||
fun start(context: Context, url: String, fileName: String) {
|
||||
context.startService<DownloadService> {
|
||||
action = IntentAction.start
|
||||
putExtra("url", url)
|
||||
putExtra("fileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Size
|
||||
import androidx.collection.LruCache
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog.putDebug
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.isEpub
|
||||
import io.legato.kazusa.help.book.isMobi
|
||||
import io.legato.kazusa.help.book.isPdf
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.localBook.EpubFile
|
||||
import io.legato.kazusa.model.localBook.MobiFile
|
||||
import io.legato.kazusa.model.localBook.PdfFile
|
||||
import io.legato.kazusa.utils.BitmapUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.SvgUtils
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageProvider {
|
||||
|
||||
private val errorBitmap: Bitmap by lazy {
|
||||
BitmapFactory.decodeResource(appCtx.resources, R.drawable.image_loading_error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存bitmap LruCache实现
|
||||
* filePath bitmap
|
||||
*/
|
||||
private const val M = 1024 * 1024
|
||||
val cacheSize: Int
|
||||
get() {
|
||||
if (AppConfig.bitmapCacheSize <= 0 || AppConfig.bitmapCacheSize >= 2048) {
|
||||
AppConfig.bitmapCacheSize = 50
|
||||
}
|
||||
return AppConfig.bitmapCacheSize * M
|
||||
}
|
||||
|
||||
val bitmapLruCache = BitmapLruCache()
|
||||
|
||||
class BitmapLruCache : LruCache<String, Bitmap>(cacheSize) {
|
||||
|
||||
private var removeCount = 0
|
||||
|
||||
val count get() = putCount() + createCount() - evictionCount() - removeCount
|
||||
|
||||
override fun sizeOf(key: String, value: Bitmap): Int {
|
||||
return value.byteCount
|
||||
}
|
||||
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String,
|
||||
oldValue: Bitmap,
|
||||
newValue: Bitmap?
|
||||
) {
|
||||
if (!evicted) {
|
||||
synchronized(this) {
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
//错误图片不能释放,占位用,防止一直重复获取图片
|
||||
if (oldValue != errorBitmap) {
|
||||
oldValue.recycle()
|
||||
//putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath")
|
||||
//putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun put(key: String, bitmap: Bitmap) {
|
||||
ensureLruCacheSize(bitmap)
|
||||
bitmapLruCache.put(key, bitmap)
|
||||
}
|
||||
|
||||
fun get(key: String): Bitmap? {
|
||||
return bitmapLruCache[key]
|
||||
}
|
||||
|
||||
fun remove(key: String): Bitmap? {
|
||||
return bitmapLruCache.remove(key)
|
||||
}
|
||||
|
||||
private fun getNotRecycled(key: String): Bitmap? {
|
||||
val bitmap = bitmapLruCache[key] ?: return null
|
||||
if (bitmap.isRecycled) {
|
||||
bitmapLruCache.remove(key)
|
||||
return null
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun ensureLruCacheSize(bitmap: Bitmap) {
|
||||
val lruMaxSize = bitmapLruCache.maxSize()
|
||||
val lruSize = bitmapLruCache.size()
|
||||
val byteCount = bitmap.byteCount
|
||||
val size = if (byteCount > lruMaxSize) {
|
||||
min(256 * M, (byteCount * 1.3).toInt())
|
||||
} else if (lruSize + byteCount > lruMaxSize && bitmapLruCache.count < 5) {
|
||||
min(256 * M, (lruSize + byteCount * 1.3).toInt())
|
||||
} else {
|
||||
lruMaxSize
|
||||
}
|
||||
if (size > lruMaxSize) {
|
||||
bitmapLruCache.resize(size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*缓存网络图片和epub图片
|
||||
*/
|
||||
suspend fun cacheImage(
|
||||
book: Book,
|
||||
src: String,
|
||||
bookSource: BookSource?
|
||||
): File {
|
||||
return withContext(IO) {
|
||||
val vFile = BookHelp.getImage(book, src)
|
||||
if (!BookHelp.isImageExist(book, src)) {
|
||||
val inputStream = when {
|
||||
book.isEpub -> EpubFile.getImage(book, src)
|
||||
book.isPdf -> PdfFile.getImage(book, src)
|
||||
book.isMobi -> MobiFile.getImage(book, src)
|
||||
else -> {
|
||||
BookHelp.saveImage(bookSource, book, src)
|
||||
null
|
||||
}
|
||||
}
|
||||
inputStream?.use { input ->
|
||||
val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath)
|
||||
FileOutputStream(newFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@withContext vFile
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*获取图片宽度高度信息
|
||||
*/
|
||||
suspend fun getImageSize(
|
||||
book: Book,
|
||||
src: String,
|
||||
bookSource: BookSource?
|
||||
): Size {
|
||||
val file = cacheImage(book, src, bookSource)
|
||||
val op = BitmapFactory.Options()
|
||||
// inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight;
|
||||
op.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeFile(file.absolutePath, op)
|
||||
if (op.outWidth < 1 && op.outHeight < 1) {
|
||||
//svg size
|
||||
val size = SvgUtils.getSize(file.absolutePath)
|
||||
if (size != null) return size
|
||||
putDebug("ImageProvider: $src Unsupported image type")
|
||||
//file.delete() 重复下载
|
||||
return Size(errorBitmap.width, errorBitmap.height)
|
||||
}
|
||||
return Size(op.outWidth, op.outHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
*获取bitmap 使用LruCache缓存
|
||||
*/
|
||||
fun getImage(
|
||||
book: Book,
|
||||
src: String,
|
||||
width: Int,
|
||||
height: Int? = null
|
||||
): Bitmap {
|
||||
//src为空白时 可能被净化替换掉了 或者规则失效
|
||||
if (book.getUseReplaceRule() && src.isBlank()) {
|
||||
book.setUseReplaceRule(false)
|
||||
appCtx.toastOnUi(R.string.error_image_url_empty)
|
||||
}
|
||||
val vFile = BookHelp.getImage(book, src)
|
||||
if (!vFile.exists()) return errorBitmap
|
||||
//epub文件提供图片链接是相对链接,同时阅读多个epub文件,缓存命中错误
|
||||
//bitmapLruCache的key同一改成缓存文件的路径
|
||||
val cacheBitmap = getNotRecycled(vFile.absolutePath)
|
||||
if (cacheBitmap != null) return cacheBitmap
|
||||
return kotlin.runCatching {
|
||||
val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
|
||||
?: SvgUtils.createBitmap(vFile.absolutePath, width, height)
|
||||
?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap))
|
||||
put(vFile.absolutePath, bitmap)
|
||||
bitmap
|
||||
}.onFailure {
|
||||
//错误图片占位,防止重复获取
|
||||
put(vFile.absolutePath, errorBitmap)
|
||||
}.getOrDefault(errorBitmap)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
bitmapLruCache.evictAll()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# 放置一些模块类
|
||||
|
||||
* analyzeRule 书源规则解析
|
||||
* localBook 本地书籍解析
|
||||
* rss 订阅规则解析
|
||||
* webBook 获取网络书籍
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.constant.IntentAction
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.HttpTTS
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.service.BaseReadAloudService
|
||||
import io.legato.kazusa.service.HttpReadAloudService
|
||||
import io.legato.kazusa.service.TTSReadAloudService
|
||||
import io.legato.kazusa.utils.LogUtils
|
||||
import io.legato.kazusa.utils.StringUtils
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import io.legato.kazusa.utils.startForegroundServiceCompat
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
object ReadAloud {
|
||||
private var aloudClass: Class<*> = getReadAloudClass()
|
||||
val ttsEngine get() = ReadBook.book?.getTtsEngine() ?: AppConfig.ttsEngine
|
||||
var httpTTS: HttpTTS? = null
|
||||
|
||||
private fun getReadAloudClass(): Class<*> {
|
||||
val ttsEngine = ttsEngine
|
||||
if (ttsEngine.isNullOrBlank()) {
|
||||
return TTSReadAloudService::class.java
|
||||
}
|
||||
if (StringUtils.isNumeric(ttsEngine)) {
|
||||
httpTTS = appDb.httpTTSDao.get(ttsEngine.toLong())
|
||||
if (httpTTS != null) {
|
||||
return HttpReadAloudService::class.java
|
||||
}
|
||||
}
|
||||
return TTSReadAloudService::class.java
|
||||
}
|
||||
|
||||
fun upReadAloudClass() {
|
||||
stop(appCtx)
|
||||
aloudClass = getReadAloudClass()
|
||||
}
|
||||
|
||||
fun play(
|
||||
context: Context,
|
||||
play: Boolean = true,
|
||||
pageIndex: Int = ReadBook.durPageIndex,
|
||||
startPos: Int = 0
|
||||
) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.play
|
||||
intent.putExtra("play", play)
|
||||
intent.putExtra("pageIndex", pageIndex)
|
||||
intent.putExtra("startPos", startPos)
|
||||
LogUtils.d("ReadAloud", intent.toString())
|
||||
try {
|
||||
context.startForegroundServiceCompat(intent)
|
||||
} catch (e: Exception) {
|
||||
val msg = "启动朗读服务出错\n${e.localizedMessage}"
|
||||
AppLog.put(msg, e)
|
||||
context.toastOnUi(msg)
|
||||
}
|
||||
}
|
||||
|
||||
fun playByEventBus(
|
||||
play: Boolean = true,
|
||||
pageIndex: Int = ReadBook.durPageIndex,
|
||||
startPos: Int = 0
|
||||
) {
|
||||
val bundle = Bundle().apply {
|
||||
putBoolean("play", play)
|
||||
putInt("pageIndex", pageIndex)
|
||||
putInt("startPos", startPos)
|
||||
}
|
||||
postEvent(EventBus.READ_ALOUD_PLAY, bundle)
|
||||
}
|
||||
|
||||
fun pause(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.pause
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.resume
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.stop
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun prevParagraph(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.prevParagraph
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun nextParagraph(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.nextParagraph
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun upTtsSpeechRate(context: Context) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.upTtsSpeechRate
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimer(context: Context, minute: Int) {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
val intent = Intent(context, aloudClass)
|
||||
intent.action = IntentAction.setTimer
|
||||
intent.putExtra("minute", minute)
|
||||
context.startForegroundServiceCompat(intent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,990 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.constant.PageAnim.scrollPageAnim
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookProgress
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.ReadRecord
|
||||
import io.legato.kazusa.help.AppWebDav
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.ContentProcessor
|
||||
import io.legato.kazusa.help.book.isImage
|
||||
import io.legato.kazusa.help.book.isLocal
|
||||
import io.legato.kazusa.help.book.isPdf
|
||||
import io.legato.kazusa.help.book.isSameNameAuthor
|
||||
import io.legato.kazusa.help.book.readSimulating
|
||||
import io.legato.kazusa.help.book.simulatedTotalChapterNum
|
||||
import io.legato.kazusa.help.book.update
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.globalExecutor
|
||||
import io.legato.kazusa.model.localBook.TextFile
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.service.BaseReadAloudService
|
||||
import io.legato.kazusa.service.CacheBookService
|
||||
import io.legato.kazusa.ui.book.read.page.entities.TextChapter
|
||||
import io.legato.kazusa.ui.book.read.page.provider.ChapterProvider
|
||||
import io.legato.kazusa.ui.book.read.page.provider.LayoutProgressListener
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
object ReadBook : CoroutineScope by MainScope() {
|
||||
var book: Book? = null
|
||||
var callBack: CallBack? = null
|
||||
var inBookshelf = false
|
||||
var chapterSize = 0
|
||||
var simulatedChapterSize = 0
|
||||
var durChapterIndex = 0
|
||||
var durChapterPos = 0
|
||||
var isLocalBook = true
|
||||
var chapterChanged = false
|
||||
var prevTextChapter: TextChapter? = null
|
||||
set(value) {
|
||||
field?.cancelLayout()
|
||||
field = value
|
||||
}
|
||||
var curTextChapter: TextChapter? = null
|
||||
set(value) {
|
||||
field?.cancelLayout()
|
||||
field = value
|
||||
}
|
||||
var nextTextChapter: TextChapter? = null
|
||||
set(value) {
|
||||
field?.cancelLayout()
|
||||
field = value
|
||||
}
|
||||
var bookSource: BookSource? = null
|
||||
var msg: String? = null
|
||||
private val loadingChapters = arrayListOf<Int>()
|
||||
private val readRecord = ReadRecord()
|
||||
var readStartTime: Long = System.currentTimeMillis()
|
||||
|
||||
/* 跳转进度前进度记录 */
|
||||
var lastBookProgress: BookProgress? = null
|
||||
|
||||
/* web端阅读进度记录 */
|
||||
var webBookProgress: BookProgress? = null
|
||||
|
||||
var preDownloadTask: Job? = null
|
||||
val downloadedChapters = hashSetOf<Int>()
|
||||
val downloadFailChapters = hashMapOf<Int, Int>()
|
||||
var contentProcessor: ContentProcessor? = null
|
||||
val downloadScope = CoroutineScope(SupervisorJob() + IO)
|
||||
val preDownloadSemaphore = Semaphore(2)
|
||||
val executor = globalExecutor
|
||||
|
||||
fun resetData(book: Book) {
|
||||
ReadBook.book = book
|
||||
readRecord.bookName = book.name
|
||||
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
contentProcessor = ContentProcessor.get(book)
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
isLocalBook = book.isLocal
|
||||
clearTextChapter()
|
||||
callBack?.upContent()
|
||||
callBack?.upMenuView()
|
||||
callBack?.upPageAnim()
|
||||
upWebBook(book)
|
||||
lastBookProgress = null
|
||||
webBookProgress = null
|
||||
TextFile.clear()
|
||||
synchronized(this) {
|
||||
loadingChapters.clear()
|
||||
downloadedChapters.clear()
|
||||
downloadFailChapters.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun upData(book: Book) {
|
||||
ReadBook.book = book
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
if (durChapterIndex != book.durChapterIndex) {
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
clearTextChapter()
|
||||
}
|
||||
if (curTextChapter?.isCompleted == false) {
|
||||
curTextChapter = null
|
||||
}
|
||||
if (nextTextChapter?.isCompleted == false) {
|
||||
nextTextChapter = null
|
||||
}
|
||||
if (prevTextChapter?.isCompleted == false) {
|
||||
prevTextChapter = null
|
||||
}
|
||||
callBack?.upMenuView()
|
||||
upWebBook(book)
|
||||
synchronized(this) {
|
||||
loadingChapters.clear()
|
||||
downloadedChapters.clear()
|
||||
downloadFailChapters.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun upWebBook(book: Book) {
|
||||
if (book.isLocal) {
|
||||
bookSource = null
|
||||
if (book.getImageStyle().isNullOrBlank() && (book.isImage || book.isPdf)) {
|
||||
book.setImageStyle(Book.imgStyleFull)
|
||||
}
|
||||
} else {
|
||||
appDb.bookSourceDao.getBookSource(book.origin)?.let {
|
||||
bookSource = it
|
||||
if (book.getImageStyle().isNullOrBlank()) {
|
||||
var imageStyle = it.getContentRule().imageStyle
|
||||
if (imageStyle.isNullOrBlank() && (book.isImage || book.isPdf)) {
|
||||
imageStyle = Book.imgStyleFull
|
||||
}
|
||||
book.setImageStyle(imageStyle)
|
||||
if (imageStyle.equals(Book.imgStyleSingle, true)) {
|
||||
book.setPageAnim(0)
|
||||
}
|
||||
}
|
||||
} ?: let {
|
||||
bookSource = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upReadBookConfig(book: Book) {
|
||||
val oldIndex = ReadBookConfig.styleSelect
|
||||
ReadBookConfig.isComic = book.isImage
|
||||
if (oldIndex != ReadBookConfig.styleSelect) {
|
||||
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
|
||||
if (AppConfig.readBarStyleFollowPage) {
|
||||
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(progress: BookProgress) {
|
||||
if (progress.durChapterIndex < chapterSize &&
|
||||
(durChapterIndex != progress.durChapterIndex
|
||||
|| durChapterPos != progress.durChapterPos)
|
||||
) {
|
||||
durChapterIndex = progress.durChapterIndex
|
||||
durChapterPos = progress.durChapterPos
|
||||
saveRead()
|
||||
clearTextChapter()
|
||||
callBack?.upContent()
|
||||
loadContent(resetPageOffset = true)
|
||||
}
|
||||
}
|
||||
|
||||
//暂时保存跳转前进度
|
||||
fun saveCurrentBookProgress() {
|
||||
if (lastBookProgress != null) return //避免进度条连续跳转不能覆盖最初的进度记录
|
||||
lastBookProgress = book?.let { BookProgress(it) }
|
||||
}
|
||||
|
||||
//恢复跳转前进度
|
||||
fun restoreLastBookProgress() {
|
||||
lastBookProgress?.let {
|
||||
setProgress(it)
|
||||
lastBookProgress = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTextChapter() {
|
||||
prevTextChapter = null
|
||||
curTextChapter = null
|
||||
nextTextChapter = null
|
||||
}
|
||||
|
||||
fun clearSearchResult() {
|
||||
curTextChapter?.clearSearchResult()
|
||||
prevTextChapter?.clearSearchResult()
|
||||
nextTextChapter?.clearSearchResult()
|
||||
}
|
||||
|
||||
fun uploadProgress(successAction: (() -> Unit)? = null) {
|
||||
book?.let {
|
||||
launch(IO) {
|
||||
AppWebDav.uploadBookProgress(it)
|
||||
ensureActive()
|
||||
it.update()
|
||||
successAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步阅读进度
|
||||
* 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作
|
||||
*/
|
||||
fun syncProgress(
|
||||
newProgressAction: ((progress: BookProgress) -> Unit)? = null,
|
||||
uploadSuccessAction: (() -> Unit)? = null,
|
||||
syncSuccessAction: (() -> Unit)? = null
|
||||
) {
|
||||
if (!AppConfig.syncBookProgress) return
|
||||
val book = book ?: return
|
||||
Coroutine.async {
|
||||
AppWebDav.getBookProgress(book)
|
||||
}.onError {
|
||||
AppLog.put("拉取阅读进度失败", it)
|
||||
}.onSuccess { progress ->
|
||||
if (progress == null || progress.durChapterIndex < book.durChapterIndex ||
|
||||
(progress.durChapterIndex == book.durChapterIndex
|
||||
&& progress.durChapterPos < book.durChapterPos)
|
||||
) {
|
||||
// 服务器没有进度或者进度比服务器快,上传现有进度
|
||||
Coroutine.async {
|
||||
AppWebDav.uploadBookProgress(BookProgress(book), uploadSuccessAction)
|
||||
book.update()
|
||||
}
|
||||
} else if (progress.durChapterIndex > book.durChapterIndex ||
|
||||
progress.durChapterPos > book.durChapterPos
|
||||
) {
|
||||
// 进度比服务器慢,执行传入动作
|
||||
newProgressAction?.invoke(progress)
|
||||
} else {
|
||||
syncSuccessAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upReadTime() {
|
||||
executor.execute {
|
||||
if (!AppConfig.enableReadRecord) {
|
||||
return@execute
|
||||
}
|
||||
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
|
||||
readStartTime = System.currentTimeMillis()
|
||||
readRecord.lastRead = System.currentTimeMillis()
|
||||
appDb.readRecordDao.insert(readRecord)
|
||||
}
|
||||
}
|
||||
|
||||
fun upMsg(msg: String?) {
|
||||
if (ReadBook.msg != msg) {
|
||||
ReadBook.msg = msg
|
||||
callBack?.upContent()
|
||||
}
|
||||
}
|
||||
|
||||
fun moveToNextPage(): Boolean {
|
||||
var hasNextPage = false
|
||||
curTextChapter?.let {
|
||||
val nextPagePos = it.getNextPageLength(durChapterPos)
|
||||
if (nextPagePos >= 0) {
|
||||
hasNextPage = true
|
||||
it.getPage(durPageIndex)?.removePageAloudSpan()
|
||||
durChapterPos = nextPagePos
|
||||
callBack?.cancelSelect()
|
||||
callBack?.upContent()
|
||||
saveRead(true)
|
||||
}
|
||||
}
|
||||
return hasNextPage
|
||||
}
|
||||
|
||||
fun moveToPrevPage(): Boolean {
|
||||
var hasPrevPage = false
|
||||
curTextChapter?.let {
|
||||
val prevPagePos = it.getPrevPageLength(durChapterPos)
|
||||
if (prevPagePos >= 0) {
|
||||
hasPrevPage = true
|
||||
durChapterPos = prevPagePos
|
||||
callBack?.upContent()
|
||||
saveRead(true)
|
||||
}
|
||||
}
|
||||
return hasPrevPage
|
||||
}
|
||||
|
||||
fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean {
|
||||
if (durChapterIndex < simulatedChapterSize - 1) {
|
||||
durChapterPos = 0
|
||||
durChapterIndex++
|
||||
prevTextChapter = curTextChapter
|
||||
curTextChapter = nextTextChapter
|
||||
nextTextChapter = null
|
||||
if (curTextChapter == null) {
|
||||
AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
|
||||
if (upContentInPlace) callBack?.upContent()
|
||||
loadContent(durChapterIndex, upContent, resetPageOffset = false)
|
||||
} else if (upContent && upContentInPlace) {
|
||||
AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
|
||||
callBack?.upContent()
|
||||
}
|
||||
loadContent(durChapterIndex.plus(1), upContent, false)
|
||||
saveRead()
|
||||
callBack?.upMenuView()
|
||||
AppLog.putDebug("moveToNextChapter-curPageChanged()")
|
||||
curPageChanged()
|
||||
return true
|
||||
} else {
|
||||
AppLog.putDebug("跳转下一章失败,没有下一章")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun moveToNextChapterAwait(
|
||||
upContent: Boolean,
|
||||
upContentInPlace: Boolean = true
|
||||
): Boolean {
|
||||
if (durChapterIndex < simulatedChapterSize - 1) {
|
||||
durChapterPos = 0
|
||||
durChapterIndex++
|
||||
prevTextChapter = curTextChapter
|
||||
curTextChapter = nextTextChapter
|
||||
nextTextChapter = null
|
||||
if (curTextChapter == null) {
|
||||
AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
|
||||
if (upContentInPlace) callBack?.upContentAwait()
|
||||
loadContentAwait(durChapterIndex, upContent, resetPageOffset = false)
|
||||
} else if (upContent && upContentInPlace) {
|
||||
AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
|
||||
callBack?.upContentAwait()
|
||||
}
|
||||
loadContent(durChapterIndex.plus(1), upContent, false)
|
||||
saveRead()
|
||||
callBack?.upMenuView()
|
||||
AppLog.putDebug("moveToNextChapter-curPageChanged()")
|
||||
curPageChanged()
|
||||
return true
|
||||
} else {
|
||||
AppLog.putDebug("跳转下一章失败,没有下一章")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun moveToPrevChapter(
|
||||
upContent: Boolean,
|
||||
toLast: Boolean = true,
|
||||
upContentInPlace: Boolean = true
|
||||
): Boolean {
|
||||
if (durChapterIndex > 0) {
|
||||
durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
|
||||
durChapterIndex--
|
||||
nextTextChapter = curTextChapter
|
||||
curTextChapter = prevTextChapter
|
||||
prevTextChapter = null
|
||||
if (curTextChapter == null) {
|
||||
if (upContentInPlace) callBack?.upContent()
|
||||
loadContent(durChapterIndex, upContent, resetPageOffset = false)
|
||||
} else if (upContent && upContentInPlace) {
|
||||
callBack?.upContent()
|
||||
}
|
||||
loadContent(durChapterIndex.minus(1), upContent, false)
|
||||
saveRead()
|
||||
callBack?.upMenuView()
|
||||
curPageChanged()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun skipToPage(index: Int, success: (() -> Unit)? = null) {
|
||||
durChapterPos = curTextChapter?.getReadLength(index) ?: index
|
||||
callBack?.upContent {
|
||||
success?.invoke()
|
||||
}
|
||||
curPageChanged()
|
||||
saveRead(true)
|
||||
}
|
||||
|
||||
fun setPageIndex(index: Int) {
|
||||
recycleRecorders(durPageIndex, index)
|
||||
durChapterPos = curTextChapter?.getReadLength(index) ?: index
|
||||
saveRead(true)
|
||||
curPageChanged(true)
|
||||
}
|
||||
|
||||
fun recycleRecorders(beforeIndex: Int, afterIndex: Int) {
|
||||
if (!AppConfig.optimizeRender) {
|
||||
return
|
||||
}
|
||||
executor.execute {
|
||||
val textChapter = curTextChapter ?: return@execute
|
||||
if (afterIndex > beforeIndex) {
|
||||
textChapter.getPage(afterIndex - 2)?.recycleRecorders()
|
||||
}
|
||||
if (afterIndex < beforeIndex) {
|
||||
textChapter.getPage(afterIndex + 3)?.recycleRecorders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) {
|
||||
if (index < chapterSize) {
|
||||
clearTextChapter()
|
||||
callBack?.upContent()
|
||||
durChapterIndex = index
|
||||
ReadBook.durChapterPos = durChapterPos
|
||||
saveRead()
|
||||
loadContent(resetPageOffset = true) {
|
||||
success?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前页面变化
|
||||
*/
|
||||
private fun curPageChanged(pageChanged: Boolean = false) {
|
||||
callBack?.pageChanged()
|
||||
curTextChapter?.let {
|
||||
if (BaseReadAloudService.isRun && it.isCompleted) {
|
||||
val scrollPageAnim = pageAnim() == 3
|
||||
if (scrollPageAnim && pageChanged) {
|
||||
ReadAloud.pause(appCtx)
|
||||
} else {
|
||||
readAloud(!BaseReadAloudService.pause)
|
||||
}
|
||||
}
|
||||
}
|
||||
upReadTime()
|
||||
preDownload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 朗读
|
||||
*/
|
||||
fun readAloud(play: Boolean = true, startPos: Int = 0) {
|
||||
book ?: return
|
||||
val textChapter = curTextChapter ?: return
|
||||
if (textChapter.isCompleted) {
|
||||
ReadAloud.play(appCtx, play, startPos = startPos)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前页数
|
||||
*/
|
||||
val durPageIndex: Int
|
||||
get() {
|
||||
return curTextChapter?.getPageIndexByCharIndex(durChapterPos) ?: durChapterPos
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否排版到了当前阅读位置
|
||||
*/
|
||||
val isLayoutAvailable inline get() = durPageIndex >= 0
|
||||
|
||||
val isScroll inline get() = pageAnim() == scrollPageAnim
|
||||
|
||||
val contentLoadFinish get() = curTextChapter != null || msg != null
|
||||
|
||||
/**
|
||||
* chapterOnDur: 0为当前页,1为下一页,-1为上一页
|
||||
*/
|
||||
fun textChapter(chapterOnDur: Int = 0): TextChapter? {
|
||||
return when (chapterOnDur) {
|
||||
0 -> curTextChapter
|
||||
1 -> nextTextChapter
|
||||
-1 -> prevTextChapter
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前章节和前后一章内容
|
||||
* @param resetPageOffset 滚动阅读是否重置滚动位置
|
||||
* @param success 当前章节加载完成回调
|
||||
*/
|
||||
fun loadContent(
|
||||
resetPageOffset: Boolean,
|
||||
success: (() -> Unit)? = null
|
||||
) {
|
||||
loadContent(durChapterIndex, resetPageOffset = resetPageOffset) {
|
||||
success?.invoke()
|
||||
}
|
||||
loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
|
||||
loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
|
||||
}
|
||||
|
||||
fun loadOrUpContent() {
|
||||
if (curTextChapter == null) {
|
||||
loadContent(durChapterIndex)
|
||||
} else {
|
||||
callBack?.upContent()
|
||||
}
|
||||
if (nextTextChapter == null) {
|
||||
loadContent(durChapterIndex + 1)
|
||||
}
|
||||
if (prevTextChapter == null) {
|
||||
loadContent(durChapterIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载章节内容
|
||||
* @param index 章节序号
|
||||
* @param upContent 是否更新视图
|
||||
* @param resetPageOffset 滚动阅读是否重置滚动位置
|
||||
* @param success 加载完成回调
|
||||
*/
|
||||
fun loadContent(
|
||||
index: Int,
|
||||
upContent: Boolean = true,
|
||||
resetPageOffset: Boolean = false,
|
||||
success: (() -> Unit)? = null
|
||||
) {
|
||||
Coroutine.async {
|
||||
val book = book!!
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return@async
|
||||
if (addLoading(index)) {
|
||||
BookHelp.getContent(book, chapter)?.let {
|
||||
contentLoadFinish(
|
||||
book,
|
||||
chapter,
|
||||
it,
|
||||
upContent,
|
||||
resetPageOffset,
|
||||
success = success
|
||||
)
|
||||
} ?: download(
|
||||
downloadScope,
|
||||
chapter,
|
||||
resetPageOffset
|
||||
)
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("加载正文出错\n${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadContentAwait(
|
||||
index: Int,
|
||||
upContent: Boolean = true,
|
||||
resetPageOffset: Boolean = false,
|
||||
success: (() -> Unit)? = null
|
||||
) = withContext(IO) {
|
||||
if (addLoading(index)) {
|
||||
try {
|
||||
val book = book!!
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!!
|
||||
val content = BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
|
||||
contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset)
|
||||
success?.invoke()
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("加载正文出错\n${e.localizedMessage}")
|
||||
} finally {
|
||||
removeLoading(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载正文
|
||||
*/
|
||||
private suspend fun downloadIndex(index: Int) {
|
||||
if (index < 0) return
|
||||
if (index > chapterSize - 1) {
|
||||
upToc()
|
||||
return
|
||||
}
|
||||
val book = book ?: return
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
|
||||
if (BookHelp.hasContent(book, chapter)) {
|
||||
downloadedChapters.add(chapter.index)
|
||||
} else {
|
||||
delay(1000)
|
||||
if (addLoading(index)) {
|
||||
download(downloadScope, chapter, false, preDownloadSemaphore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载正文
|
||||
*/
|
||||
private fun download(
|
||||
scope: CoroutineScope,
|
||||
chapter: BookChapter,
|
||||
resetPageOffset: Boolean,
|
||||
semaphore: Semaphore? = null,
|
||||
success: (() -> Unit)? = null
|
||||
) {
|
||||
val book = book ?: return removeLoading(chapter.index)
|
||||
val bookSource = bookSource
|
||||
if (bookSource != null) {
|
||||
CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
|
||||
} else {
|
||||
val msg = if (book.isLocal) "无内容" else "没有书源"
|
||||
contentLoadFinish(
|
||||
book,
|
||||
chapter,
|
||||
"加载正文失败\n$msg",
|
||||
resetPageOffset = resetPageOffset,
|
||||
success = success
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadAwait(chapter: BookChapter): String {
|
||||
val book = book!!
|
||||
val bookSource = bookSource
|
||||
if (bookSource != null) {
|
||||
return CacheBook.getOrCreate(bookSource, book).downloadAwait(chapter)
|
||||
} else {
|
||||
val msg = if (book.isLocal) "无内容" else "没有书源"
|
||||
return "加载正文失败\n$msg"
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun addLoading(index: Int): Boolean {
|
||||
if (loadingChapters.contains(index)) return false
|
||||
loadingChapters.add(index)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeLoading(index: Int) {
|
||||
loadingChapters.remove(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容加载完成
|
||||
*/
|
||||
fun contentLoadFinish(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
upContent: Boolean = true,
|
||||
resetPageOffset: Boolean,
|
||||
canceled: Boolean = false,
|
||||
success: (() -> Unit)? = null
|
||||
) {
|
||||
removeLoading(chapter.index)
|
||||
if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
|
||||
return
|
||||
}
|
||||
Coroutine.async {
|
||||
val contentProcessor = ContentProcessor.get(book.name, book.origin)
|
||||
val displayTitle = chapter.getDisplayTitle(
|
||||
contentProcessor.getTitleReplaceRules(),
|
||||
book.getUseReplaceRule()
|
||||
)
|
||||
val contents = contentProcessor
|
||||
.getContent(book, chapter, content, includeTitle = false)
|
||||
val textChapter = ChapterProvider.getTextChapterAsync(
|
||||
this@ReadBook, book, chapter, displayTitle, contents, simulatedChapterSize
|
||||
)
|
||||
when (val offset = chapter.index - durChapterIndex) {
|
||||
0 -> {
|
||||
curTextChapter = textChapter
|
||||
callBack?.upMenuView()
|
||||
var available = false
|
||||
for (page in textChapter.layoutChannel) {
|
||||
val index = page.index
|
||||
if (!available && page.containPos(durChapterPos)) {
|
||||
if (upContent) {
|
||||
callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
available = true
|
||||
}
|
||||
if (upContent && isScroll) {
|
||||
if (max(index - 3, 0) < durPageIndex) {
|
||||
callBack?.upContent(offset, false)
|
||||
}
|
||||
}
|
||||
callBack?.onLayoutPageCompleted(index, page)
|
||||
}
|
||||
if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
|
||||
curPageChanged()
|
||||
callBack?.contentLoadFinish()
|
||||
}
|
||||
|
||||
-1 -> {
|
||||
prevTextChapter = textChapter
|
||||
textChapter.layoutChannel.receiveAsFlow().collect()
|
||||
if (upContent) callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
nextTextChapter = textChapter
|
||||
for (page in textChapter.layoutChannel) {
|
||||
if (page.index > 1) {
|
||||
continue
|
||||
}
|
||||
if (upContent) callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@async
|
||||
}.onError {
|
||||
AppLog.put("ChapterProvider ERROR", it)
|
||||
appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}")
|
||||
}.onSuccess {
|
||||
success?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun contentLoadFinishAwait(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
upContent: Boolean = true,
|
||||
resetPageOffset: Boolean
|
||||
) {
|
||||
removeLoading(chapter.index)
|
||||
if (chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
|
||||
return
|
||||
}
|
||||
kotlin.runCatching {
|
||||
val contentProcessor = ContentProcessor.get(book.name, book.origin)
|
||||
val displayTitle = chapter.getDisplayTitle(
|
||||
contentProcessor.getTitleReplaceRules(),
|
||||
book.getUseReplaceRule()
|
||||
)
|
||||
val contents = contentProcessor
|
||||
.getContent(book, chapter, content, includeTitle = false)
|
||||
val textChapter = ChapterProvider.getTextChapterAsync(
|
||||
this@ReadBook, book, chapter, displayTitle, contents, simulatedChapterSize
|
||||
)
|
||||
when (val offset = chapter.index - durChapterIndex) {
|
||||
0 -> {
|
||||
curTextChapter = textChapter
|
||||
callBack?.upMenuView()
|
||||
var available = false
|
||||
for (page in textChapter.layoutChannel) {
|
||||
val index = page.index
|
||||
if (!available && page.containPos(durChapterPos)) {
|
||||
if (upContent) {
|
||||
callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
available = true
|
||||
}
|
||||
if (upContent && isScroll) {
|
||||
if (max(index - 3, 0) < durPageIndex) {
|
||||
callBack?.upContent(offset, false)
|
||||
}
|
||||
}
|
||||
callBack?.onLayoutPageCompleted(index, page)
|
||||
}
|
||||
if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
|
||||
curPageChanged()
|
||||
callBack?.contentLoadFinish()
|
||||
}
|
||||
|
||||
-1 -> {
|
||||
prevTextChapter = textChapter
|
||||
textChapter.layoutChannel.receiveAsFlow().collect()
|
||||
if (upContent) callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
nextTextChapter = textChapter
|
||||
for (page in textChapter.layoutChannel) {
|
||||
if (page.index > 1) {
|
||||
continue
|
||||
}
|
||||
if (upContent) callBack?.upContent(offset, resetPageOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}.onFailure {
|
||||
AppLog.put("ChapterProvider ERROR", it)
|
||||
appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun upToc() {
|
||||
val bookSource = bookSource ?: return
|
||||
val book = book ?: return
|
||||
if (!book.canUpdate) return
|
||||
if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
|
||||
book.lastCheckTime = System.currentTimeMillis()
|
||||
WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
|
||||
if (book.bookUrl == ReadBook.book?.bookUrl
|
||||
&& cList.size > chapterSize
|
||||
) {
|
||||
appDb.bookChapterDao.delByBook(book.bookUrl)
|
||||
appDb.bookChapterDao.insert(*cList.toTypedArray())
|
||||
saveRead()
|
||||
chapterSize = cList.size
|
||||
simulatedChapterSize = book.simulatedTotalChapterNum()
|
||||
nextTextChapter ?: loadContent(durChapterIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pageAnim(): Int {
|
||||
return book?.getPageAnim() ?: ReadBookConfig.pageAnim
|
||||
}
|
||||
|
||||
fun setCharset(charset: String) {
|
||||
book?.let {
|
||||
it.charset = charset
|
||||
callBack?.loadChapterList(it)
|
||||
}
|
||||
saveRead()
|
||||
}
|
||||
|
||||
fun saveRead(pageChanged: Boolean = false) {
|
||||
executor.execute {
|
||||
val book = book ?: return@execute
|
||||
book.lastCheckCount = 0
|
||||
book.durChapterTime = System.currentTimeMillis()
|
||||
val chapterChanged = book.durChapterIndex != durChapterIndex
|
||||
book.durChapterIndex = durChapterIndex
|
||||
book.durChapterPos = durChapterPos
|
||||
if (!pageChanged || chapterChanged) {
|
||||
appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
|
||||
book.durChapterTitle = it.getDisplayTitle(
|
||||
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
|
||||
book.getUseReplaceRule()
|
||||
)
|
||||
}
|
||||
}
|
||||
appDb.bookDao.update(book)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预下载
|
||||
*/
|
||||
private fun preDownload() {
|
||||
if (book?.isLocal == true) return
|
||||
executor.execute {
|
||||
if (AppConfig.preDownloadNum < 2) {
|
||||
return@execute
|
||||
}
|
||||
preDownloadTask?.cancel()
|
||||
preDownloadTask = launch(IO) {
|
||||
//预下载
|
||||
launch {
|
||||
val maxChapterIndex =
|
||||
min(durChapterIndex + AppConfig.preDownloadNum, chapterSize)
|
||||
for (i in durChapterIndex.plus(2)..maxChapterIndex) {
|
||||
if (downloadedChapters.contains(i)) continue
|
||||
if ((downloadFailChapters[i] ?: 0) >= 3) continue
|
||||
downloadIndex(i)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum)
|
||||
for (i in durChapterIndex.minus(2) downTo minChapterIndex) {
|
||||
if (downloadedChapters.contains(i)) continue
|
||||
if ((downloadFailChapters[i] ?: 0) >= 3) continue
|
||||
downloadIndex(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPreDownloadTask() {
|
||||
if (contentLoadFinish) {
|
||||
preDownloadTask?.cancel()
|
||||
downloadScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
}
|
||||
|
||||
fun onChapterListUpdated(newBook: Book) {
|
||||
if (newBook.isSameNameAuthor(book)) {
|
||||
book = newBook
|
||||
chapterSize = newBook.totalChapterNum
|
||||
simulatedChapterSize = newBook.simulatedTotalChapterNum()
|
||||
if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) {
|
||||
durChapterIndex = simulatedChapterSize - 1
|
||||
}
|
||||
if (callBack == null) {
|
||||
clearTextChapter()
|
||||
} else {
|
||||
loadContent(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册回调
|
||||
*/
|
||||
fun register(cb: CallBack) {
|
||||
callBack?.notifyBookChanged()
|
||||
callBack = cb
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册回调
|
||||
*/
|
||||
fun unregister(cb: CallBack) {
|
||||
if (callBack === cb) {
|
||||
callBack = null
|
||||
}
|
||||
msg = null
|
||||
preDownloadTask?.cancel()
|
||||
downloadScope.coroutineContext.cancelChildren()
|
||||
coroutineContext.cancelChildren()
|
||||
ImageProvider.clear()
|
||||
if (!CacheBookService.isRun) {
|
||||
CacheBook.close()
|
||||
}
|
||||
}
|
||||
|
||||
interface CallBack : LayoutProgressListener {
|
||||
fun upMenuView()
|
||||
|
||||
fun loadChapterList(book: Book)
|
||||
|
||||
fun upContent(
|
||||
relativePosition: Int = 0,
|
||||
resetPageOffset: Boolean = true,
|
||||
success: (() -> Unit)? = null
|
||||
)
|
||||
|
||||
suspend fun upContentAwait(
|
||||
relativePosition: Int = 0,
|
||||
resetPageOffset: Boolean = true,
|
||||
success: (() -> Unit)? = null
|
||||
)
|
||||
|
||||
fun pageChanged()
|
||||
|
||||
fun contentLoadFinish()
|
||||
|
||||
fun upPageAnim(upRecorder: Boolean = false)
|
||||
|
||||
fun notifyBookChanged()
|
||||
|
||||
fun sureNewProgress(progress: BookProgress)
|
||||
|
||||
fun cancelSelect()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookProgress
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.ReadRecord
|
||||
import io.legato.kazusa.help.AppWebDav
|
||||
import io.legato.kazusa.help.ConcurrentRateLimiter
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.ContentProcessor
|
||||
import io.legato.kazusa.help.book.isLocal
|
||||
import io.legato.kazusa.help.book.isSameNameAuthor
|
||||
import io.legato.kazusa.help.book.readSimulating
|
||||
import io.legato.kazusa.help.book.simulatedTotalChapterNum
|
||||
import io.legato.kazusa.help.book.update
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.globalExecutor
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.ui.book.manga.entities.BaseMangaPage
|
||||
import io.legato.kazusa.ui.book.manga.entities.MangaChapter
|
||||
import io.legato.kazusa.ui.book.manga.entities.MangaContent
|
||||
import io.legato.kazusa.ui.book.manga.entities.MangaPage
|
||||
import io.legato.kazusa.ui.book.manga.entities.ReaderLoading
|
||||
import io.legato.kazusa.utils.mapIndexed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
object ReadManga : CoroutineScope by MainScope() {
|
||||
var inBookshelf = false
|
||||
var book: Book? = null
|
||||
val executor = globalExecutor
|
||||
var durChapterIndex = 0 //章节位置
|
||||
var chapterSize = 0//总章节
|
||||
var durChapterPos = 0
|
||||
var chapterChanged = false
|
||||
var prevMangaChapter: MangaChapter? = null
|
||||
var curMangaChapter: MangaChapter? = null
|
||||
var nextMangaChapter: MangaChapter? = null
|
||||
var bookSource: BookSource? = null
|
||||
var readStartTime: Long = System.currentTimeMillis()
|
||||
private val readRecord = ReadRecord()
|
||||
private val loadingChapters = arrayListOf<Int>()
|
||||
var simulatedChapterSize = 0
|
||||
var mCallback: Callback? = null
|
||||
var preDownloadTask: Job? = null
|
||||
val downloadedChapters = hashSetOf<Int>()
|
||||
val downloadFailChapters = hashMapOf<Int, Int>()
|
||||
val downloadScope = CoroutineScope(SupervisorJob() + IO)
|
||||
val preDownloadSemaphore = Semaphore(2)
|
||||
var rateLimiter = ConcurrentRateLimiter(null)
|
||||
val mangaContents get() = buildMangaContent()
|
||||
val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
|
||||
|
||||
fun resetData(book: Book) {
|
||||
ReadManga.book = book
|
||||
readRecord.bookName = book.name
|
||||
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
clearMangaChapter()
|
||||
upWebBook(book)
|
||||
synchronized(this) {
|
||||
loadingChapters.clear()
|
||||
downloadedChapters.clear()
|
||||
downloadFailChapters.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun upData(book: Book) {
|
||||
ReadManga.book = book
|
||||
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
|
||||
simulatedChapterSize = if (book.readSimulating()) {
|
||||
book.simulatedTotalChapterNum()
|
||||
} else {
|
||||
chapterSize
|
||||
}
|
||||
|
||||
if (durChapterIndex != book.durChapterIndex) {
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
clearMangaChapter()
|
||||
}
|
||||
upWebBook(book)
|
||||
synchronized(this) {
|
||||
loadingChapters.clear()
|
||||
downloadedChapters.clear()
|
||||
downloadFailChapters.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun upWebBook(book: Book) {
|
||||
appDb.bookSourceDao.getBookSource(book.origin)?.let {
|
||||
bookSource = it
|
||||
rateLimiter = ConcurrentRateLimiter(it)
|
||||
} ?: let {
|
||||
bookSource = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMangaChapter() {
|
||||
prevMangaChapter = null
|
||||
curMangaChapter = null
|
||||
nextMangaChapter = null
|
||||
}
|
||||
|
||||
//每次切换章节更新阅读记录
|
||||
fun upReadTime() {
|
||||
executor.execute {
|
||||
if (!AppConfig.enableReadRecord) {
|
||||
return@execute
|
||||
}
|
||||
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
|
||||
readStartTime = System.currentTimeMillis()
|
||||
readRecord.lastRead = System.currentTimeMillis()
|
||||
appDb.readRecordDao.insert(readRecord)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun addLoading(index: Int): Boolean {
|
||||
if (loadingChapters.contains(index)) return false
|
||||
loadingChapters.add(index)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeLoading(index: Int) {
|
||||
loadingChapters.remove(index)
|
||||
}
|
||||
|
||||
fun loadContent() {
|
||||
clearMangaChapter()
|
||||
loadContent(durChapterIndex)
|
||||
loadContent(durChapterIndex + 1)
|
||||
loadContent(durChapterIndex - 1)
|
||||
}
|
||||
|
||||
fun loadOrUpContent() {
|
||||
if (curMangaChapter == null) {
|
||||
loadContent(durChapterIndex)
|
||||
} else {
|
||||
mCallback?.upContent()
|
||||
}
|
||||
if (nextMangaChapter == null) {
|
||||
loadContent(durChapterIndex + 1)
|
||||
}
|
||||
if (prevMangaChapter == null) {
|
||||
loadContent(durChapterIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContent(index: Int) {
|
||||
Coroutine.async {
|
||||
val book = book!!
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return@async
|
||||
if (addLoading(index)) {
|
||||
BookHelp.getContent(book, chapter)?.let {
|
||||
contentLoadFinish(chapter, it)
|
||||
} ?: run {
|
||||
download(downloadScope, chapter)
|
||||
}
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("加载正文出错\n${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容加载完成
|
||||
*/
|
||||
suspend fun contentLoadFinish(
|
||||
chapter: BookChapter,
|
||||
content: String?,
|
||||
errorMsg: String = "加载内容失败",
|
||||
canceled: Boolean = false
|
||||
) {
|
||||
removeLoading(chapter.index)
|
||||
if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
|
||||
return
|
||||
}
|
||||
when (val offset = chapter.index - durChapterIndex) {
|
||||
0 -> {
|
||||
if (content == null) {
|
||||
mCallback?.loadFail(errorMsg)
|
||||
return
|
||||
}
|
||||
if (content.isEmpty() && !chapter.isVolume) {
|
||||
mCallback?.loadFail("正文内容为空")
|
||||
return
|
||||
}
|
||||
val mangaChapter = getManageChapter(chapter, content)
|
||||
if (mangaChapter.imageCount == 0 && !chapter.isVolume) {
|
||||
mCallback?.loadFail("正文没有图片")
|
||||
return
|
||||
}
|
||||
curMangaChapter = mangaChapter
|
||||
mCallback?.upContent()
|
||||
}
|
||||
|
||||
-1, 1 -> {
|
||||
if (content == null || (!chapter.isVolume && content.isEmpty())) {
|
||||
return
|
||||
}
|
||||
val mangaChapter = getManageChapter(chapter, content)
|
||||
if (mangaChapter.imageCount == 0 && !chapter.isVolume) {
|
||||
return
|
||||
}
|
||||
|
||||
when (offset) {
|
||||
-1 -> prevMangaChapter = mangaChapter
|
||||
1 -> nextMangaChapter = mangaChapter
|
||||
}
|
||||
|
||||
mCallback?.upContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMangaContent(): MangaContent {
|
||||
val items = arrayListOf<BaseMangaPage>()
|
||||
var pos = 0
|
||||
var curFinish = false
|
||||
var nextFinish = false
|
||||
prevMangaChapter?.let {
|
||||
pos += it.pages.size
|
||||
items.addAll(it.pages)
|
||||
}
|
||||
curMangaChapter?.let {
|
||||
curFinish = true
|
||||
items.addAll(it.pages)
|
||||
durChapterPos = if (it.imageCount > 0) {
|
||||
durChapterPos.coerceIn(0, it.imageCount - 1)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
pos += durChapterPos
|
||||
if (!AppConfig.hideMangaTitle && it.imageCount > 0) {
|
||||
pos++
|
||||
}
|
||||
}
|
||||
nextMangaChapter?.let {
|
||||
nextFinish = true
|
||||
items.addAll(it.pages)
|
||||
}
|
||||
return MangaContent(pos, items, curFinish, nextFinish)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载下一章
|
||||
*/
|
||||
fun moveToNextChapter(toFirst: Boolean = false): Boolean {
|
||||
if (durChapterIndex < simulatedChapterSize - 1) {
|
||||
if (toFirst) {
|
||||
mCallback?.showLoading()
|
||||
durChapterPos = 0
|
||||
}
|
||||
durChapterIndex++
|
||||
prevMangaChapter = curMangaChapter
|
||||
curMangaChapter = nextMangaChapter
|
||||
nextMangaChapter = null
|
||||
if (curMangaChapter == null) {
|
||||
mCallback?.startLoad()
|
||||
loadContent(durChapterIndex)
|
||||
} else {
|
||||
mCallback?.upContent()
|
||||
}
|
||||
loadContent(durChapterIndex + 1)
|
||||
saveRead()
|
||||
AppLog.putDebug("moveToNextChapter-curPageChanged()")
|
||||
curPageChanged()
|
||||
return true
|
||||
} else {
|
||||
AppLog.putDebug("跳转下一章失败,没有下一章")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun moveToPrevChapter(toFirst: Boolean = false): Boolean {
|
||||
if (durChapterIndex > 0) {
|
||||
if (toFirst) {
|
||||
mCallback?.showLoading()
|
||||
durChapterPos = 0
|
||||
}
|
||||
durChapterIndex--
|
||||
nextMangaChapter = curMangaChapter
|
||||
curMangaChapter = prevMangaChapter
|
||||
prevMangaChapter = null
|
||||
if (curMangaChapter == null) {
|
||||
loadContent(durChapterIndex)
|
||||
} else {
|
||||
mCallback?.upContent()
|
||||
}
|
||||
loadContent(durChapterIndex - 1)
|
||||
saveRead()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun curPageChanged() {
|
||||
upReadTime()
|
||||
preDownload()
|
||||
}
|
||||
|
||||
fun saveRead(pageChanged: Boolean = false) {
|
||||
executor.execute {
|
||||
val book = book ?: return@execute
|
||||
book.lastCheckCount = 0
|
||||
book.durChapterTime = System.currentTimeMillis()
|
||||
val chapterChanged = book.durChapterIndex != durChapterIndex
|
||||
book.durChapterIndex = durChapterIndex
|
||||
book.durChapterPos = durChapterPos
|
||||
if (!pageChanged || chapterChanged) {
|
||||
appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
|
||||
book.durChapterTitle = it.getDisplayTitle(
|
||||
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
|
||||
book.getUseReplaceRule()
|
||||
)
|
||||
}
|
||||
}
|
||||
appDb.bookDao.update(book)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadNetworkContent(
|
||||
bookSource: BookSource,
|
||||
scope: CoroutineScope,
|
||||
chapter: BookChapter,
|
||||
book: Book,
|
||||
semaphore: Semaphore?,
|
||||
success: suspend (String) -> Unit = {},
|
||||
error: suspend () -> Unit = {},
|
||||
cancel: suspend () -> Unit = {},
|
||||
) {
|
||||
WebBook.getContent(
|
||||
scope,
|
||||
bookSource,
|
||||
book,
|
||||
chapter,
|
||||
start = CoroutineStart.LAZY,
|
||||
executeContext = IO,
|
||||
semaphore = semaphore
|
||||
).onSuccess { content ->
|
||||
success.invoke(content)
|
||||
}.onError {
|
||||
error.invoke()
|
||||
}.onCancel {
|
||||
cancel.invoke()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun preDownload() {
|
||||
if (book?.isLocal == true) return
|
||||
executor.execute {
|
||||
if (AppConfig.preDownloadNum < 2) {
|
||||
return@execute
|
||||
}
|
||||
preDownloadTask?.cancel()
|
||||
preDownloadTask = launch(IO) {
|
||||
//预下载
|
||||
launch {
|
||||
val maxChapterIndex =
|
||||
min(durChapterIndex + AppConfig.preDownloadNum, chapterSize)
|
||||
for (i in durChapterIndex.plus(2)..maxChapterIndex) {
|
||||
if (downloadedChapters.contains(i)) continue
|
||||
if ((downloadFailChapters[i] ?: 0) >= 3) continue
|
||||
downloadIndex(i)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum)
|
||||
for (i in durChapterIndex.minus(2) downTo minChapterIndex) {
|
||||
if (downloadedChapters.contains(i)) continue
|
||||
if ((downloadFailChapters[i] ?: 0) >= 3) continue
|
||||
downloadIndex(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPreDownloadTask() {
|
||||
if (curMangaChapter != null && nextMangaChapter != null) {
|
||||
preDownloadTask?.cancel()
|
||||
downloadScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadIndex(index: Int) {
|
||||
if (index < 0) return
|
||||
if (index > chapterSize - 1) {
|
||||
upToc()
|
||||
return
|
||||
}
|
||||
val book = book ?: return
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
|
||||
if (BookHelp.hasContent(book, chapter)) {
|
||||
downloadedChapters.add(chapter.index)
|
||||
} else {
|
||||
delay(1000)
|
||||
if (addLoading(index)) {
|
||||
download(downloadScope, chapter, preDownloadSemaphore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取正文
|
||||
*/
|
||||
private suspend fun download(
|
||||
scope: CoroutineScope,
|
||||
chapter: BookChapter,
|
||||
semaphore: Semaphore? = null,
|
||||
) {
|
||||
val book = book ?: return removeLoading(chapter.index)
|
||||
val bookSource = bookSource
|
||||
if (bookSource != null) {
|
||||
downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = {
|
||||
downloadedChapters.add(chapter.index)
|
||||
downloadFailChapters.remove(chapter.index)
|
||||
contentLoadFinish(chapter, it)
|
||||
}, error = {
|
||||
downloadFailChapters[chapter.index] =
|
||||
(downloadFailChapters[chapter.index] ?: 0) + 1
|
||||
contentLoadFinish(chapter, null)
|
||||
}, cancel = {
|
||||
contentLoadFinish(chapter, null, canceled = true)
|
||||
})
|
||||
} else {
|
||||
contentLoadFinish(chapter, null, "加载内容失败 没有书源")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun upToc() {
|
||||
val bookSource = bookSource ?: return
|
||||
val book = book ?: return
|
||||
if (!book.canUpdate) return
|
||||
if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
|
||||
book.lastCheckTime = System.currentTimeMillis()
|
||||
WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
|
||||
if (book.bookUrl == ReadManga.book?.bookUrl
|
||||
&& cList.size > chapterSize
|
||||
) {
|
||||
appDb.bookChapterDao.delByBook(book.bookUrl)
|
||||
appDb.bookChapterDao.insert(*cList.toTypedArray())
|
||||
saveRead()
|
||||
chapterSize = cList.size
|
||||
simulatedChapterSize = book.simulatedTotalChapterNum()
|
||||
nextMangaChapter ?: loadContent(durChapterIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadProgress(successAction: (() -> Unit)? = null) {
|
||||
book?.let {
|
||||
launch(IO) {
|
||||
AppWebDav.uploadBookProgress(it)
|
||||
ensureActive()
|
||||
it.update()
|
||||
successAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步阅读进度
|
||||
* 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作
|
||||
*/
|
||||
fun syncProgress(
|
||||
newProgressAction: ((progress: BookProgress) -> Unit)? = null,
|
||||
uploadSuccessAction: (() -> Unit)? = null,
|
||||
syncSuccessAction: (() -> Unit)? = null,
|
||||
) {
|
||||
if (!AppConfig.syncBookProgress) return
|
||||
val book = book ?: return
|
||||
Coroutine.async {
|
||||
AppWebDav.getBookProgress(book)
|
||||
}.onError {
|
||||
AppLog.put("拉取阅读进度失败", it)
|
||||
}.onSuccess { progress ->
|
||||
if (progress == null || progress.durChapterIndex < book.durChapterIndex ||
|
||||
(progress.durChapterIndex == book.durChapterIndex
|
||||
&& progress.durChapterPos < book.durChapterPos)
|
||||
) {
|
||||
// 服务器没有进度或者进度比服务器快,上传现有进度
|
||||
Coroutine.async {
|
||||
AppWebDav.uploadBookProgress(BookProgress(book), uploadSuccessAction)
|
||||
book.update()
|
||||
}
|
||||
} else if (progress.durChapterIndex > book.durChapterIndex ||
|
||||
progress.durChapterPos > book.durChapterPos
|
||||
) {
|
||||
// 进度比服务器慢,执行传入动作
|
||||
newProgressAction?.invoke(progress)
|
||||
} else {
|
||||
syncSuccessAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(progress: BookProgress) {
|
||||
if (progress.durChapterIndex < chapterSize &&
|
||||
(durChapterIndex != progress.durChapterIndex
|
||||
|| durChapterPos != progress.durChapterPos)
|
||||
) {
|
||||
mCallback?.showLoading()
|
||||
if (progress.durChapterIndex == durChapterIndex) {
|
||||
durChapterPos = progress.durChapterPos
|
||||
mCallback?.upContent()
|
||||
} else {
|
||||
durChapterIndex = progress.durChapterIndex
|
||||
durChapterPos = progress.durChapterPos
|
||||
loadContent()
|
||||
}
|
||||
saveRead()
|
||||
}
|
||||
}
|
||||
|
||||
fun showLoading() {
|
||||
mCallback?.showLoading()
|
||||
}
|
||||
|
||||
fun onChapterListUpdated(newBook: Book) {
|
||||
if (newBook.isSameNameAuthor(book)) {
|
||||
book = newBook
|
||||
chapterSize = newBook.totalChapterNum
|
||||
simulatedChapterSize = newBook.simulatedTotalChapterNum()
|
||||
if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) {
|
||||
durChapterIndex = simulatedChapterSize - 1
|
||||
}
|
||||
if (mCallback == null) {
|
||||
clearMangaChapter()
|
||||
} else {
|
||||
loadContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册回调
|
||||
*/
|
||||
fun register(cb: Callback) {
|
||||
mCallback = cb
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册回调
|
||||
*/
|
||||
fun unregister(cb: Callback) {
|
||||
if (mCallback === cb) {
|
||||
mCallback = null
|
||||
}
|
||||
preDownloadTask?.cancel()
|
||||
preDownloadTask = null
|
||||
downloadScope.coroutineContext.cancelChildren()
|
||||
coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
private suspend fun getManageChapter(chapter: BookChapter, content: String): MangaChapter {
|
||||
val list = BookHelp.flowImages(chapter, content)
|
||||
.distinctUntilChanged().mapIndexed { index, src ->
|
||||
MangaPage(
|
||||
chapterIndex = chapter.index,
|
||||
chapterSize = chapterSize,
|
||||
mImageUrl = src,
|
||||
index = index,
|
||||
mChapterName = chapter.title
|
||||
)
|
||||
}.toList()
|
||||
|
||||
val imageCount = list.size
|
||||
|
||||
list.forEach {
|
||||
it.imageCount = imageCount
|
||||
}
|
||||
|
||||
if (AppConfig.hideMangaTitle && imageCount > 0) {
|
||||
return MangaChapter(chapter, list, imageCount)
|
||||
}
|
||||
|
||||
val pages = mutableListOf<BaseMangaPage>()
|
||||
|
||||
if (imageCount == 0 && chapter.isVolume) {
|
||||
pages.add(ReaderLoading(chapter.index, -1, chapter.title, true))
|
||||
} else {
|
||||
pages.add(ReaderLoading(chapter.index, -1, "阅读 ${chapter.title}"))
|
||||
pages.addAll(list)
|
||||
pages.add(ReaderLoading(chapter.index, imageCount, "已读完 ${chapter.title}"))
|
||||
}
|
||||
|
||||
return MangaChapter(chapter, pages, imageCount)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun upContent()
|
||||
fun loadFail(msg: String)
|
||||
fun sureNewProgress(progress: BookProgress)
|
||||
fun showLoading()
|
||||
fun startLoad()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.legato.kazusa.model
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.script.ScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.http.newCallStrResponse
|
||||
import io.legato.kazusa.help.http.okHttpClient
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.isAbsUrl
|
||||
import io.legato.kazusa.utils.isJsonObject
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.mozilla.javascript.Scriptable
|
||||
import org.mozilla.javascript.ScriptableObject
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object SharedJsScope {
|
||||
|
||||
private val cacheFolder = File(appCtx.cacheDir, "shareJs")
|
||||
private val aCache = ACache.get(cacheFolder)
|
||||
|
||||
private val scopeMap = LruCache<String, WeakReference<Scriptable>>(16)
|
||||
|
||||
fun getScope(jsLib: String?, coroutineContext: CoroutineContext?): Scriptable? {
|
||||
if (jsLib.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
val key = MD5Utils.md5Encode(jsLib)
|
||||
var scope = scopeMap[key]?.get()
|
||||
if (scope == null) {
|
||||
scope = RhinoScriptEngine.run {
|
||||
getRuntimeScope(ScriptBindings())
|
||||
}
|
||||
if (jsLib.isJsonObject()) {
|
||||
val jsMap: Map<String, String> = GSON.fromJson(
|
||||
jsLib,
|
||||
TypeToken.getParameterized(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
String::class.java
|
||||
).type
|
||||
)
|
||||
jsMap.values.forEach { value ->
|
||||
if (value.isAbsUrl()) {
|
||||
val fileName = MD5Utils.md5Encode(value)
|
||||
var js = aCache.getAsString(fileName)
|
||||
if (js == null) {
|
||||
js = runBlocking {
|
||||
okHttpClient.newCallStrResponse {
|
||||
url(value)
|
||||
}.body
|
||||
}
|
||||
if (js != null) {
|
||||
aCache.put(fileName, js)
|
||||
} else {
|
||||
throw NoStackTraceException("下载jsLib-${value}失败")
|
||||
}
|
||||
}
|
||||
RhinoScriptEngine.eval(js, scope, coroutineContext)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RhinoScriptEngine.eval(jsLib, scope, coroutineContext)
|
||||
}
|
||||
if (scope is ScriptableObject) {
|
||||
scope.sealObject()
|
||||
}
|
||||
scopeMap.put(key, WeakReference(scope))
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
fun remove(jsLib: String?) {
|
||||
if (jsLib.isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
val key = MD5Utils.md5Encode(jsLib)
|
||||
scopeMap.remove(key)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.jayway.jsonpath.JsonPath
|
||||
import com.jayway.jsonpath.ReadContext
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
|
||||
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
@Keep
|
||||
class AnalyzeByJSonPath(json: Any) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun parse(json: Any): ReadContext {
|
||||
return when (json) {
|
||||
is ReadContext -> json
|
||||
is String -> JsonPath.parse(json) //JsonPath.parse<String>(json)
|
||||
else -> JsonPath.parse(json) //JsonPath.parse<Any>(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ctx: ReadContext = parse(json)
|
||||
|
||||
/**
|
||||
* 改进解析方法
|
||||
* 解决阅读”&&“、”||“与jsonPath支持的”&&“、”||“之间的冲突
|
||||
* 解决{$.rule}形式规则可能匹配错误的问题,旧规则用正则解析内容含‘}’的json文本时,用规则中的字段去匹配这种内容会匹配错误.现改用平衡嵌套方法解决这个问题
|
||||
* */
|
||||
fun getString(rule: String): String? {
|
||||
if (rule.isEmpty()) return null
|
||||
var result: String
|
||||
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||")
|
||||
|
||||
if (rules.size == 1) {
|
||||
|
||||
ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器
|
||||
|
||||
result = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...}
|
||||
|
||||
if (result.isEmpty()) { //st为空,表明无成功替换的内嵌规则
|
||||
try {
|
||||
val ob = ctx.read<Any>(rule)
|
||||
result = if (ob is List<*>) {
|
||||
ob.joinToString("\n")
|
||||
} else {
|
||||
ob.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
val textList = arrayListOf<String>()
|
||||
for (rl in rules) {
|
||||
val temp = getString(rl)
|
||||
if (!temp.isNullOrEmpty()) {
|
||||
textList.add(temp)
|
||||
if (ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return textList.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getStringList(rule: String): List<String> {
|
||||
val result = ArrayList<String>()
|
||||
if (rule.isEmpty()) return result
|
||||
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
|
||||
if (rules.size == 1) {
|
||||
ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器
|
||||
val st = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...}
|
||||
if (st.isEmpty()) { //st为空,表明无成功替换的内嵌规则
|
||||
try {
|
||||
val obj = ctx.read<Any>(rule)
|
||||
if (obj is List<*>) {
|
||||
for (o in obj) result.add(o.toString())
|
||||
} else {
|
||||
result.add(obj.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
} else {
|
||||
result.add(st)
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
val results = ArrayList<List<String>>()
|
||||
for (rl in rules) {
|
||||
val temp = getStringList(rl)
|
||||
if (temp.isNotEmpty()) {
|
||||
results.add(temp)
|
||||
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in results[0].indices) {
|
||||
for (temp in results) {
|
||||
if (i < temp.size) {
|
||||
result.add(temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (temp in results) {
|
||||
result.addAll(temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getObject(rule: String): Any {
|
||||
return ctx.read(rule)
|
||||
}
|
||||
|
||||
internal fun getList(rule: String): ArrayList<Any>? {
|
||||
val result = ArrayList<Any>()
|
||||
if (rule.isEmpty()) return result
|
||||
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
if (rules.size == 1) {
|
||||
ctx.let {
|
||||
try {
|
||||
return it.read<ArrayList<Any>>(rules[0])
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val results = ArrayList<ArrayList<*>>()
|
||||
for (rl in rules) {
|
||||
val temp = getList(rl)
|
||||
if (!temp.isNullOrEmpty()) {
|
||||
results.add(temp)
|
||||
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in 0 until results[0].size) {
|
||||
for (temp in results) {
|
||||
if (i < temp.size) {
|
||||
temp[i]?.let { result.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (temp in results) {
|
||||
result.addAll(temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.jsoup.select.Collector
|
||||
import org.jsoup.select.Elements
|
||||
import org.jsoup.select.Evaluator
|
||||
import org.seimicrawler.xpath.JXNode
|
||||
|
||||
/**
|
||||
* Created by GKF on 2018/1/25.
|
||||
* 书源规则解析
|
||||
*/
|
||||
@Keep
|
||||
class AnalyzeByJSoup(doc: Any) {
|
||||
|
||||
companion object {
|
||||
private val nullSet = setOf(null)
|
||||
}
|
||||
|
||||
private var element: Element = parse(doc)
|
||||
|
||||
private fun parse(doc: Any): Element {
|
||||
if (doc is Element) {
|
||||
return doc
|
||||
}
|
||||
if (doc is JXNode) {
|
||||
return if (doc.isElement) doc.asElement() else Jsoup.parse(doc.toString())
|
||||
}
|
||||
kotlin.runCatching {
|
||||
if (doc.toString().startsWith("<?xml", true)) {
|
||||
return Jsoup.parse(doc.toString(), Parser.xmlParser())
|
||||
}
|
||||
}
|
||||
return Jsoup.parse(doc.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*/
|
||||
internal fun getElements(rule: String) = getElements(element, rule)
|
||||
|
||||
/**
|
||||
* 合并内容列表,得到内容
|
||||
*/
|
||||
internal fun getString(ruleStr: String): String? {
|
||||
if (ruleStr.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val list = getStringList(ruleStr)
|
||||
if (list.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
if (list.size == 1) {
|
||||
return list.first()
|
||||
}
|
||||
return list.joinToString("\n")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取一个字符串
|
||||
*/
|
||||
internal fun getString0(ruleStr: String) =
|
||||
getStringList(ruleStr).let { if (it.isEmpty()) "" else it[0] }
|
||||
|
||||
/**
|
||||
* 获取所有内容列表
|
||||
*/
|
||||
internal fun getStringList(ruleStr: String): List<String> {
|
||||
|
||||
val textS = ArrayList<String>()
|
||||
|
||||
if (ruleStr.isEmpty()) return textS
|
||||
|
||||
//拆分规则
|
||||
val sourceRule = SourceRule(ruleStr)
|
||||
|
||||
if (sourceRule.elementsRule.isEmpty()) {
|
||||
|
||||
textS.add(element.data() ?: "")
|
||||
|
||||
} else {
|
||||
|
||||
val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule)
|
||||
val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
|
||||
val results = ArrayList<List<String>>()
|
||||
for (ruleStrX in ruleStrS) {
|
||||
|
||||
val temp: ArrayList<String>? =
|
||||
if (sourceRule.isCss) {
|
||||
val lastIndex = ruleStrX.lastIndexOf('@')
|
||||
getResultLast(
|
||||
element.select(ruleStrX.substring(0, lastIndex)),
|
||||
ruleStrX.substring(lastIndex + 1)
|
||||
)
|
||||
} else {
|
||||
getResultList(ruleStrX)
|
||||
}
|
||||
|
||||
if (!temp.isNullOrEmpty()) {
|
||||
results.add(temp)
|
||||
if (ruleAnalyzes.elementsType == "||") break
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in results[0].indices) {
|
||||
for (temp in results) {
|
||||
if (i < temp.size) {
|
||||
textS.add(temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (temp in results) {
|
||||
textS.addAll(temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return textS
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Elements
|
||||
*/
|
||||
private fun getElements(temp: Element?, rule: String): Elements {
|
||||
|
||||
if (temp == null || rule.isEmpty()) return Elements()
|
||||
|
||||
val elements = Elements()
|
||||
|
||||
val sourceRule = SourceRule(rule)
|
||||
val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule)
|
||||
val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
|
||||
val elementsList = ArrayList<Elements>()
|
||||
if (sourceRule.isCss) {
|
||||
for (ruleStr in ruleStrS) {
|
||||
val tempS = temp.select(ruleStr)
|
||||
elementsList.add(tempS)
|
||||
if (tempS.size > 0 && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (ruleStr in ruleStrS) {
|
||||
|
||||
val rsRule = RuleAnalyzer(ruleStr)
|
||||
|
||||
rsRule.trim() // 修剪当前规则之前的"@"或者空白符
|
||||
|
||||
val rs = rsRule.splitRule("@")
|
||||
|
||||
val el = if (rs.size > 1) {
|
||||
val el = Elements()
|
||||
el.add(temp)
|
||||
for (rl in rs) {
|
||||
val es = Elements()
|
||||
for (et in el) {
|
||||
es.addAll(getElements(et, rl))
|
||||
}
|
||||
el.clear()
|
||||
el.addAll(es)
|
||||
}
|
||||
el
|
||||
} else ElementsSingle().getElementsSingle(temp, ruleStr)
|
||||
|
||||
elementsList.add(el)
|
||||
if (el.size > 0 && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (elementsList.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in 0 until elementsList[0].size) {
|
||||
for (es in elementsList) {
|
||||
if (i < es.size) {
|
||||
elements.add(es[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (es in elementsList) {
|
||||
elements.addAll(es)
|
||||
}
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容列表
|
||||
*/
|
||||
private fun getResultList(ruleStr: String): ArrayList<String>? {
|
||||
|
||||
if (ruleStr.isEmpty()) return null
|
||||
|
||||
var elements = Elements()
|
||||
|
||||
elements.add(element)
|
||||
|
||||
val rule = RuleAnalyzer(ruleStr) //创建解析
|
||||
|
||||
rule.trim() //修建前置赘余符号
|
||||
|
||||
val rules = rule.splitRule("@") // 切割成列表
|
||||
|
||||
val last = rules.size - 1
|
||||
for (i in 0 until last) {
|
||||
val es = Elements()
|
||||
for (elt in elements) {
|
||||
es.addAll(ElementsSingle().getElementsSingle(elt, rules[i]))
|
||||
}
|
||||
elements.clear()
|
||||
elements = es
|
||||
}
|
||||
return if (elements.isEmpty()) null else getResultLast(elements, rules[last])
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据最后一个规则获取内容
|
||||
*/
|
||||
private fun getResultLast(elements: Elements, lastRule: String): ArrayList<String> {
|
||||
val textS = ArrayList<String>()
|
||||
when (lastRule) {
|
||||
"text" -> for (element in elements) {
|
||||
val text = element.text()
|
||||
if (text.isNotEmpty()) {
|
||||
textS.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
"textNodes" -> for (element in elements) {
|
||||
val tn = arrayListOf<String>()
|
||||
val contentEs = element.textNodes()
|
||||
for (item in contentEs) {
|
||||
val text = item.text().trim { it <= ' ' }
|
||||
if (text.isNotEmpty()) {
|
||||
tn.add(text)
|
||||
}
|
||||
}
|
||||
if (tn.isNotEmpty()) {
|
||||
textS.add(tn.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
"ownText" -> for (element in elements) {
|
||||
val text = element.ownText()
|
||||
if (text.isNotEmpty()) {
|
||||
textS.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
"html" -> {
|
||||
elements.select("script").remove()
|
||||
elements.select("style").remove()
|
||||
val html = elements.outerHtml()
|
||||
if (html.isNotEmpty()) {
|
||||
textS.add(html)
|
||||
}
|
||||
}
|
||||
|
||||
"all" -> textS.add(elements.outerHtml())
|
||||
else -> for (element in elements) {
|
||||
|
||||
val url = element.attr(lastRule)
|
||||
|
||||
if (url.isBlank() || textS.contains(url)) continue
|
||||
|
||||
textS.add(url)
|
||||
}
|
||||
}
|
||||
return textS
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.支持阅读原有写法,':'分隔索引,!或.表示筛选方式,索引可为负数
|
||||
* 例如 tag.div.-1:10:2 或 tag.div!0:3
|
||||
*
|
||||
* 2. 支持与jsonPath类似的[]索引写法
|
||||
* 格式形如 [it,it,。。。] 或 [!it,it,。。。] 其中[!开头表示筛选方式为排除,it为单个索引或区间。
|
||||
* 区间格式为 start:end 或 start:end:step,其中start为0可省略,end为-1可省略。
|
||||
* 索引,区间两端及间隔都支持负数
|
||||
* 例如 tag.div[-1, 3:-2:-10, 2]
|
||||
* 特殊用法 tag.div[-1:0] 可在任意地方让列表反向
|
||||
* */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
data class ElementsSingle(
|
||||
var split: Char = '.',
|
||||
var beforeRule: String = "",
|
||||
val indexDefault: MutableList<Int> = mutableListOf(),
|
||||
val indexes: MutableList<Any> = mutableListOf()
|
||||
) {
|
||||
/**
|
||||
* 获取Elements按照一个规则
|
||||
*/
|
||||
fun getElementsSingle(temp: Element, rule: String): Elements {
|
||||
|
||||
findIndexSet(rule) //执行索引列表处理器
|
||||
|
||||
/**
|
||||
* 获取所有元素
|
||||
* */
|
||||
var elements =
|
||||
if (beforeRule.isEmpty()) temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同
|
||||
else {
|
||||
val rules = beforeRule.split(".")
|
||||
when (rules[0]) {
|
||||
"children" -> temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同
|
||||
"class" -> temp.getElementsByClass(rules[1])
|
||||
"tag" -> temp.getElementsByTag(rules[1])
|
||||
"id" -> Collector.collect(Evaluator.Id(rules[1]), temp)
|
||||
"text" -> temp.getElementsContainingOwnText(rules[1])
|
||||
else -> temp.select(beforeRule)
|
||||
}
|
||||
}
|
||||
|
||||
val len = elements.size
|
||||
val lastIndexes = (indexDefault.size - 1).takeIf { it != -1 } ?: (indexes.size - 1)
|
||||
val indexSet = mutableSetOf<Int>()
|
||||
|
||||
/**
|
||||
* 获取无重且不越界的索引集合
|
||||
* */
|
||||
if (indexes.isEmpty()) for (ix in lastIndexes downTo 0) { //indexes为空,表明是非[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序
|
||||
|
||||
val it = indexDefault[ix]
|
||||
if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合
|
||||
else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合
|
||||
|
||||
} else for (ix in lastIndexes downTo 0) { //indexes不空,表明是[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序
|
||||
|
||||
if (indexes[ix] is Triple<*, *, *>) { //区间
|
||||
val (startX, endX, stepX) = indexes[ix] as Triple<Int?, Int?, Int> //还原储存时的类型
|
||||
|
||||
var start = startX ?: 0 // 左端省略表示0
|
||||
if (start < 0) start += len // 将负索引转正
|
||||
|
||||
var end = endX ?: (len - 1) // 右端省略表示 len - 1
|
||||
if (end < 0) end += len // 将负索引转正
|
||||
|
||||
if ((start < 0 && end < 0) || (start >= len && end >= len)) {
|
||||
// start 和 end 同侧左右端越界,无效索引
|
||||
continue
|
||||
}
|
||||
|
||||
if (start >= len) start = len - 1 // 右端越界,设置为最大索引
|
||||
else if (start < 0) start = 0 // 左端越界,设置为最小索引
|
||||
|
||||
if (end >= len) end = len - 1 // 右端越界,设置为最大索引
|
||||
else if (end < 0) end = 0 // 左端越界,设置为最小索引
|
||||
|
||||
if (start == end || stepX >= len) { //两端相同,区间里只有一个数。或间隔过大,区间实际上仅有首位
|
||||
|
||||
indexSet.add(start)
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
val step =
|
||||
if (stepX > 0) stepX else if (-stepX < len) stepX + len else 1 //最小正数间隔为1
|
||||
|
||||
//将区间展开到集合中,允许列表反向。
|
||||
indexSet.addAll(if (end > start) start..end step step else start downTo end step step)
|
||||
|
||||
} else {//单个索引
|
||||
|
||||
val it = indexes[ix] as Int //还原储存时的类型
|
||||
|
||||
if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合
|
||||
else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据索引集合筛选元素
|
||||
* */
|
||||
if (split == '!') { //排除
|
||||
|
||||
for (pcInt in indexSet) elements[pcInt] = null
|
||||
|
||||
elements.removeAll(nullSet) //测试过,这样就行
|
||||
|
||||
} else if (split == '.') { //选择
|
||||
|
||||
val es = Elements()
|
||||
|
||||
for (pcInt in indexSet) es.add(elements[pcInt])
|
||||
|
||||
elements = es
|
||||
|
||||
}
|
||||
|
||||
return elements //返回筛选结果
|
||||
|
||||
}
|
||||
|
||||
private fun findIndexSet(rule: String) {
|
||||
|
||||
val rus = rule.trim { it <= ' ' }
|
||||
|
||||
var len = rus.length
|
||||
var curInt: Int? //当前数字
|
||||
var curMinus = false //当前数字是否为负
|
||||
val curList = mutableListOf<Int?>() //当前数字区间
|
||||
var l = "" //暂存数字字符串
|
||||
|
||||
val head = rus.last() == ']' //是否为常规索引写法
|
||||
|
||||
if (head) { //常规索引写法[index...]
|
||||
|
||||
len-- //跳过尾部']'
|
||||
|
||||
while (len-- >= 0) { //逆向遍历,可以无前置规则
|
||||
|
||||
var rl = rus[len]
|
||||
if (rl == ' ') continue //跳过空格
|
||||
|
||||
if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出
|
||||
else if (rl == '-') curMinus = true
|
||||
else {
|
||||
|
||||
curInt =
|
||||
if (l.isEmpty()) null else if (curMinus) -l.toInt() else l.toInt() //当前数字
|
||||
|
||||
when (rl) {
|
||||
|
||||
':' -> curList.add(curInt) //区间右端或区间间隔
|
||||
|
||||
else -> {
|
||||
|
||||
//为保证查找顺序,区间和单个索引都添加到同一集合
|
||||
if (curList.isEmpty()) {
|
||||
|
||||
if (curInt == null) break //是jsoup选择器而非索引列表,跳出
|
||||
|
||||
indexes.add(curInt)
|
||||
} else {
|
||||
|
||||
//列表最后压入的是区间右端,若列表有两位则最先压入的是间隔
|
||||
indexes.add(
|
||||
Triple(
|
||||
curInt,
|
||||
curList.last(),
|
||||
if (curList.size == 2) curList.first() else 1
|
||||
)
|
||||
)
|
||||
|
||||
curList.clear() //重置临时列表,避免影响到下个区间的处理
|
||||
|
||||
}
|
||||
|
||||
if (rl == '!') {
|
||||
split = '!'
|
||||
do {
|
||||
rl = rus[--len]
|
||||
} while (len > 0 && rl == ' ')//跳过所有空格
|
||||
}
|
||||
|
||||
if (rl == '[') {
|
||||
beforeRule = rus.substring(0, len) //遇到索引边界,返回结果
|
||||
return
|
||||
}
|
||||
|
||||
if (rl != ',') break //非索引结构,跳出
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
l = "" //清空
|
||||
curMinus = false //重置
|
||||
}
|
||||
}
|
||||
} else while (len-- >= 0) { //阅读原本写法,逆向遍历,可以无前置规则
|
||||
|
||||
val rl = rus[len]
|
||||
if (rl == ' ') continue //跳过空格
|
||||
|
||||
if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出
|
||||
else if (rl == '-') curMinus = true
|
||||
else {
|
||||
|
||||
if (rl == '!' || rl == '.' || rl == ':') { //分隔符或起始符
|
||||
|
||||
indexDefault.add(if (curMinus) -l.toInt() else l.toInt()) // 当前数字追加到列表
|
||||
|
||||
if (rl != ':') { //rl == '!' || rl == '.'
|
||||
split = rl
|
||||
beforeRule = rus.substring(0, len)
|
||||
return
|
||||
}
|
||||
|
||||
} else break //非索引结构,跳出循环
|
||||
|
||||
l = "" //清空
|
||||
curMinus = false //重置
|
||||
}
|
||||
}
|
||||
|
||||
split = ' '
|
||||
beforeRule = rus
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal inner class SourceRule(ruleStr: String) {
|
||||
var isCss = false
|
||||
var elementsRule: String = if (ruleStr.startsWith("@CSS:", true)) {
|
||||
isCss = true
|
||||
ruleStr.substring(5).trim { it <= ' ' }
|
||||
} else {
|
||||
ruleStr
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Keep
|
||||
object AnalyzeByRegex {
|
||||
|
||||
fun getElement(res: String, regs: Array<String>, index: Int = 0): List<String>? {
|
||||
var vIndex = index
|
||||
val resM = Pattern.compile(regs[vIndex]).matcher(res)
|
||||
if (!resM.find()) {
|
||||
return null
|
||||
}
|
||||
// 判断索引的规则是最后一个规则
|
||||
return if (vIndex + 1 == regs.size) {
|
||||
// 新建容器
|
||||
val info = arrayListOf<String>()
|
||||
for (groupIndex in 0..resM.groupCount()) {
|
||||
info.add(resM.group(groupIndex)!!)
|
||||
}
|
||||
info
|
||||
} else {
|
||||
val result = StringBuilder()
|
||||
do {
|
||||
result.append(resM.group())
|
||||
} while (resM.find())
|
||||
getElement(result.toString(), regs, ++vIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getElements(res: String, regs: Array<String>, index: Int = 0): List<List<String>> {
|
||||
var vIndex = index
|
||||
val resM = Pattern.compile(regs[vIndex]).matcher(res)
|
||||
if (!resM.find()) {
|
||||
return arrayListOf()
|
||||
}
|
||||
// 判断索引的规则是最后一个规则
|
||||
if (vIndex + 1 == regs.size) {
|
||||
// 创建书息缓存数组
|
||||
val books = ArrayList<List<String>>()
|
||||
// 提取列表
|
||||
do {
|
||||
// 新建容器
|
||||
val info = arrayListOf<String>()
|
||||
for (groupIndex in 0..resM.groupCount()) {
|
||||
info.add(resM.group(groupIndex) ?: "")
|
||||
}
|
||||
books.add(info)
|
||||
} while (resM.find())
|
||||
return books
|
||||
} else {
|
||||
val result = StringBuilder()
|
||||
do {
|
||||
result.append(resM.group())
|
||||
} while (resM.find())
|
||||
return getElements(result.toString(), regs, ++vIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.Keep
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.jsoup.select.Elements
|
||||
import org.seimicrawler.xpath.JXDocument
|
||||
import org.seimicrawler.xpath.JXNode
|
||||
|
||||
@Keep
|
||||
class AnalyzeByXPath(doc: Any) {
|
||||
private var jxNode: Any = parse(doc)
|
||||
|
||||
private fun parse(doc: Any): Any {
|
||||
return when (doc) {
|
||||
is JXNode -> if (doc.isElement) doc else strToJXDocument(doc.toString())
|
||||
is Document -> JXDocument.create(doc)
|
||||
is Element -> JXDocument.create(Elements(doc))
|
||||
is Elements -> JXDocument.create(doc)
|
||||
else -> strToJXDocument(doc.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun strToJXDocument(html: String): JXDocument {
|
||||
var html1 = html
|
||||
if (html1.endsWith("</td>")) {
|
||||
html1 = "<tr>${html1}</tr>"
|
||||
}
|
||||
if (html1.endsWith("</tr>") || html1.endsWith("</tbody>")) {
|
||||
html1 = "<table>${html1}</table>"
|
||||
}
|
||||
kotlin.runCatching {
|
||||
if (html1.trim().startsWith("<?xml", true)) {
|
||||
return JXDocument.create(Jsoup.parse(html1, Parser.xmlParser()))
|
||||
}
|
||||
}
|
||||
return JXDocument.create(html1)
|
||||
}
|
||||
|
||||
private fun getResult(xPath: String): List<JXNode>? {
|
||||
val node = jxNode
|
||||
return if (node is JXNode) {
|
||||
node.sel(xPath)
|
||||
} else {
|
||||
(node as JXDocument).selN(xPath)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getElements(xPath: String): List<JXNode>? {
|
||||
|
||||
if (xPath.isEmpty()) return null
|
||||
|
||||
val jxNodes = ArrayList<JXNode>()
|
||||
val ruleAnalyzes = RuleAnalyzer(xPath)
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
|
||||
if (rules.size == 1) {
|
||||
return getResult(rules[0])
|
||||
} else {
|
||||
val results = ArrayList<List<JXNode>>()
|
||||
for (rl in rules) {
|
||||
val temp = getElements(rl)
|
||||
if (!temp.isNullOrEmpty()) {
|
||||
results.add(temp)
|
||||
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in results[0].indices) {
|
||||
for (temp in results) {
|
||||
if (i < temp.size) {
|
||||
jxNodes.add(temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (temp in results) {
|
||||
jxNodes.addAll(temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jxNodes
|
||||
}
|
||||
|
||||
internal fun getStringList(xPath: String): List<String> {
|
||||
|
||||
val result = ArrayList<String>()
|
||||
val ruleAnalyzes = RuleAnalyzer(xPath)
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
|
||||
|
||||
if (rules.size == 1) {
|
||||
getResult(xPath)?.map {
|
||||
result.add(it.asString())
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
val results = ArrayList<List<String>>()
|
||||
for (rl in rules) {
|
||||
val temp = getStringList(rl)
|
||||
if (temp.isNotEmpty()) {
|
||||
results.add(temp)
|
||||
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
if ("%%" == ruleAnalyzes.elementsType) {
|
||||
for (i in results[0].indices) {
|
||||
for (temp in results) {
|
||||
if (i < temp.size) {
|
||||
result.add(temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (temp in results) {
|
||||
result.addAll(temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun getString(rule: String): String? {
|
||||
val ruleAnalyzes = RuleAnalyzer(rule)
|
||||
val rules = ruleAnalyzes.splitRule("&&", "||")
|
||||
if (rules.size == 1) {
|
||||
getResult(rule)?.let {
|
||||
return TextUtils.join("\n", it)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
val textList = arrayListOf<String>()
|
||||
for (rl in rules) {
|
||||
val temp = getString(rl)
|
||||
if (!temp.isNullOrEmpty()) {
|
||||
textList.add(temp)
|
||||
if (ruleAnalyzes.elementsType == "||") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return textList.joinToString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.Keep
|
||||
import com.script.CompiledScript
|
||||
import com.script.buildScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.constant.AppPattern.JS_PATTERN
|
||||
import io.legato.kazusa.data.entities.BaseBook
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.RssArticle
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.JsExtensions
|
||||
import io.legato.kazusa.help.http.CookieStore
|
||||
import io.legato.kazusa.help.source.getShareScope
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.webBook.WebBook
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.GSONStrict
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getOrPutLimit
|
||||
import io.legato.kazusa.utils.isDataUrl
|
||||
import io.legato.kazusa.utils.isJson
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import io.legato.kazusa.utils.splitNotBlank
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
import org.jsoup.nodes.Node
|
||||
import org.mozilla.javascript.NativeObject
|
||||
import org.mozilla.javascript.Scriptable
|
||||
import java.lang.ref.WeakReference
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.coroutines.ContinuationInterceptor
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
/**
|
||||
* 解析规则获取结果
|
||||
*/
|
||||
@Keep
|
||||
@Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate")
|
||||
class AnalyzeRule(
|
||||
private var ruleData: RuleDataInterface? = null,
|
||||
private val source: BaseSource? = null,
|
||||
private val preUpdateJs: Boolean = false
|
||||
) : JsExtensions {
|
||||
|
||||
private val book get() = ruleData as? BaseBook
|
||||
private val rssArticle get() = ruleData as? RssArticle
|
||||
|
||||
private var chapter: BookChapter? = null
|
||||
private var nextChapterUrl: String? = null
|
||||
private var content: Any? = null
|
||||
private var baseUrl: String? = null
|
||||
private var redirectUrl: URL? = null
|
||||
private var isJSON: Boolean = false
|
||||
private var isRegex: Boolean = false
|
||||
|
||||
private var analyzeByXPath: AnalyzeByXPath? = null
|
||||
private var analyzeByJSoup: AnalyzeByJSoup? = null
|
||||
private var analyzeByJSonPath: AnalyzeByJSonPath? = null
|
||||
|
||||
private val stringRuleCache = hashMapOf<String, List<SourceRule>>()
|
||||
private val regexCache = hashMapOf<String, Regex?>()
|
||||
private val scriptCache = hashMapOf<String, CompiledScript>()
|
||||
private var topScopeRef: WeakReference<Scriptable>? = null
|
||||
private var evalJSCallCount = 0
|
||||
|
||||
private var coroutineContext: CoroutineContext = EmptyCoroutineContext
|
||||
|
||||
private var loggedNonStandardJSON = false
|
||||
|
||||
@JvmOverloads
|
||||
fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule {
|
||||
if (content == null) throw AssertionError("内容不可空(Content cannot be null)")
|
||||
this.content = content
|
||||
isJSON = when (content) {
|
||||
is Node -> false
|
||||
else -> content.toString().isJson()
|
||||
}
|
||||
setBaseUrl(baseUrl)
|
||||
analyzeByXPath = null
|
||||
analyzeByJSoup = null
|
||||
analyzeByJSonPath = null
|
||||
return this
|
||||
}
|
||||
|
||||
fun setBaseUrl(baseUrl: String?): AnalyzeRule {
|
||||
baseUrl?.let {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun setRedirectUrl(url: String): URL? {
|
||||
if (url.isDataUrl()) {
|
||||
return redirectUrl
|
||||
}
|
||||
try {
|
||||
redirectUrl = URL(url)
|
||||
} catch (e: Exception) {
|
||||
log("URL($url) error\n${e.localizedMessage}")
|
||||
}
|
||||
return redirectUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取XPath解析类
|
||||
*/
|
||||
private fun getAnalyzeByXPath(o: Any): AnalyzeByXPath {
|
||||
return if (o != content) {
|
||||
AnalyzeByXPath(o)
|
||||
} else {
|
||||
if (analyzeByXPath == null) {
|
||||
analyzeByXPath = AnalyzeByXPath(content!!)
|
||||
}
|
||||
analyzeByXPath!!
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSOUP解析类
|
||||
*/
|
||||
private fun getAnalyzeByJSoup(o: Any): AnalyzeByJSoup {
|
||||
return if (o != content) {
|
||||
AnalyzeByJSoup(o)
|
||||
} else {
|
||||
if (analyzeByJSoup == null) {
|
||||
analyzeByJSoup = AnalyzeByJSoup(content!!)
|
||||
}
|
||||
analyzeByJSoup!!
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSON解析类
|
||||
*/
|
||||
private fun getAnalyzeByJSonPath(o: Any): AnalyzeByJSonPath {
|
||||
return if (o != content) {
|
||||
AnalyzeByJSonPath(o)
|
||||
} else {
|
||||
if (analyzeByJSonPath == null) {
|
||||
analyzeByJSonPath = AnalyzeByJSonPath(content!!)
|
||||
}
|
||||
analyzeByJSonPath!!
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本列表
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List<String>? {
|
||||
if (rule.isNullOrEmpty()) return null
|
||||
val ruleList = splitSourceRuleCacheString(rule)
|
||||
return getStringList(ruleList, mContent, isUrl)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun getStringList(
|
||||
ruleList: List<SourceRule>,
|
||||
mContent: Any? = null,
|
||||
isUrl: Boolean = false
|
||||
): List<String>? {
|
||||
var result: Any? = null
|
||||
val content = mContent ?: this.content
|
||||
if (content != null && ruleList.isNotEmpty()) {
|
||||
result = content
|
||||
if (result is NativeObject) {
|
||||
val sourceRule = ruleList.first()
|
||||
putRule(sourceRule.putMap)
|
||||
sourceRule.makeUpRule(result)
|
||||
result = if (sourceRule.getParamSize() > 1) {
|
||||
// get {{}}
|
||||
sourceRule.rule
|
||||
} else {
|
||||
// 键值直接访问
|
||||
result[sourceRule.rule]
|
||||
}
|
||||
result?.let {
|
||||
if (sourceRule.replaceRegex.isNotEmpty() && it is List<*>) {
|
||||
result = it.map { o ->
|
||||
replaceRegex(o.toString(), sourceRule)
|
||||
}
|
||||
} else if (sourceRule.replaceRegex.isNotEmpty()) {
|
||||
result = replaceRegex(result.toString(), sourceRule)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (sourceRule in ruleList) {
|
||||
putRule(sourceRule.putMap)
|
||||
sourceRule.makeUpRule(result)
|
||||
result ?: continue
|
||||
val rule = sourceRule.rule
|
||||
if (rule.isNotEmpty()) {
|
||||
result = when (sourceRule.mode) {
|
||||
Mode.Js -> evalJS(rule, result)
|
||||
Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
|
||||
Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
|
||||
Mode.Default -> getAnalyzeByJSoup(result).getStringList(rule)
|
||||
else -> rule
|
||||
}
|
||||
}
|
||||
if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) {
|
||||
val newList = ArrayList<String>()
|
||||
for (item in result) {
|
||||
newList.add(replaceRegex(item.toString(), sourceRule))
|
||||
}
|
||||
result = newList
|
||||
} else if (sourceRule.replaceRegex.isNotEmpty()) {
|
||||
result = replaceRegex(result.toString(), sourceRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == null) return null
|
||||
if (result is String) {
|
||||
result = result.split("\n")
|
||||
}
|
||||
if (isUrl) {
|
||||
val urlList = ArrayList<String>()
|
||||
if (result is List<*>) {
|
||||
for (url in result) {
|
||||
val absoluteURL = NetworkUtils.getAbsoluteURL(redirectUrl, url.toString())
|
||||
if (absoluteURL.isNotEmpty() && !urlList.contains(absoluteURL)) {
|
||||
urlList.add(absoluteURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return urlList
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as? List<String>
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String {
|
||||
if (TextUtils.isEmpty(ruleStr)) return ""
|
||||
val ruleList = splitSourceRuleCacheString(ruleStr)
|
||||
return getString(ruleList, mContent, isUrl)
|
||||
}
|
||||
|
||||
fun getString(ruleStr: String?, unescape: Boolean): String {
|
||||
if (TextUtils.isEmpty(ruleStr)) return ""
|
||||
val ruleList = splitSourceRuleCacheString(ruleStr)
|
||||
return getString(ruleList, unescape = unescape)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun getString(
|
||||
ruleList: List<SourceRule>,
|
||||
mContent: Any? = null,
|
||||
isUrl: Boolean = false,
|
||||
unescape: Boolean = true
|
||||
): String {
|
||||
var result: Any? = null
|
||||
val content = mContent ?: this.content
|
||||
if (content != null && ruleList.isNotEmpty()) {
|
||||
result = content
|
||||
if (result is NativeObject) {
|
||||
val sourceRule = ruleList.first()
|
||||
putRule(sourceRule.putMap)
|
||||
sourceRule.makeUpRule(result)
|
||||
result = if (sourceRule.getParamSize() > 1) {
|
||||
// get {{}}
|
||||
sourceRule.rule
|
||||
} else {
|
||||
// 键值直接访问
|
||||
result[sourceRule.rule]?.toString()
|
||||
}?.let {
|
||||
replaceRegex(it, sourceRule)
|
||||
}
|
||||
} else {
|
||||
for (sourceRule in ruleList) {
|
||||
putRule(sourceRule.putMap)
|
||||
sourceRule.makeUpRule(result)
|
||||
result ?: continue
|
||||
val rule = sourceRule.rule
|
||||
if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
|
||||
result = when (sourceRule.mode) {
|
||||
Mode.Js -> evalJS(rule, result)
|
||||
Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
|
||||
Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
|
||||
Mode.Default -> if (isUrl) {
|
||||
getAnalyzeByJSoup(result).getString0(rule)
|
||||
} else {
|
||||
getAnalyzeByJSoup(result).getString(rule)
|
||||
}
|
||||
|
||||
else -> rule
|
||||
}
|
||||
}
|
||||
if (result != null && sourceRule.replaceRegex.isNotEmpty()) {
|
||||
result = replaceRegex(result.toString(), sourceRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == null) result = ""
|
||||
val resultStr = result.toString()
|
||||
val str = if (unescape && resultStr.indexOf('&') > -1) {
|
||||
StringEscapeUtils.unescapeHtml4(resultStr)
|
||||
} else {
|
||||
resultStr
|
||||
}
|
||||
if (isUrl) {
|
||||
return if (str.isBlank()) {
|
||||
baseUrl ?: ""
|
||||
} else {
|
||||
NetworkUtils.getAbsoluteURL(redirectUrl, str)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Element
|
||||
*/
|
||||
fun getElement(ruleStr: String): Any? {
|
||||
if (TextUtils.isEmpty(ruleStr)) return null
|
||||
var result: Any? = null
|
||||
val content = this.content
|
||||
val ruleList = splitSourceRule(ruleStr, true)
|
||||
if (content != null && ruleList.isNotEmpty()) {
|
||||
result = content
|
||||
for (sourceRule in ruleList) {
|
||||
putRule(sourceRule.putMap)
|
||||
sourceRule.makeUpRule(result)
|
||||
result ?: continue
|
||||
val rule = sourceRule.rule
|
||||
result = when (sourceRule.mode) {
|
||||
Mode.Regex -> AnalyzeByRegex.getElement(
|
||||
result.toString(),
|
||||
rule.splitNotBlank("&&")
|
||||
)
|
||||
|
||||
Mode.Js -> evalJS(rule, result)
|
||||
Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
|
||||
Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
|
||||
else -> getAnalyzeByJSoup(result).getElements(rule)
|
||||
}
|
||||
if (sourceRule.replaceRegex.isNotEmpty()) {
|
||||
result = replaceRegex(result.toString(), sourceRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun getElements(ruleStr: String): List<Any> {
|
||||
var result: Any? = null
|
||||
val content = this.content
|
||||
val ruleList = splitSourceRule(ruleStr, true)
|
||||
if (content != null && ruleList.isNotEmpty()) {
|
||||
result = content
|
||||
for (sourceRule in ruleList) {
|
||||
putRule(sourceRule.putMap)
|
||||
result ?: continue
|
||||
val rule = sourceRule.rule
|
||||
result = when (sourceRule.mode) {
|
||||
Mode.Regex -> AnalyzeByRegex.getElements(
|
||||
result.toString(),
|
||||
rule.splitNotBlank("&&")
|
||||
)
|
||||
|
||||
Mode.Js -> evalJS(rule, result)
|
||||
Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
|
||||
Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
|
||||
else -> getAnalyzeByJSoup(result).getElements(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
result?.let {
|
||||
return it as List<Any>
|
||||
}
|
||||
return ArrayList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存变量
|
||||
*/
|
||||
private fun putRule(map: Map<String, String>) {
|
||||
for ((key, value) in map) {
|
||||
put(key, getString(value))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分离put规则
|
||||
*/
|
||||
private fun splitPutRule(ruleStr: String, putMap: HashMap<String, String>): String {
|
||||
var vRuleStr = ruleStr
|
||||
val putMatcher = putPattern.matcher(vRuleStr)
|
||||
while (putMatcher.find()) {
|
||||
vRuleStr = vRuleStr.replace(putMatcher.group(), "")
|
||||
val putJsonStr = putMatcher.group(1)
|
||||
val putJson = GSONStrict.fromJsonObject<Map<String, String>>(putJsonStr)
|
||||
.getOrNull()
|
||||
if (putJson != null) {
|
||||
putMap.putAll(putJson)
|
||||
continue
|
||||
}
|
||||
GSON.fromJsonObject<Map<String, String>>(putJsonStr)
|
||||
.getOrNull()
|
||||
?.let {
|
||||
if (!loggedNonStandardJSON) {
|
||||
Debug.log("≡@put 规则 JSON 格式不规范,请改为规范格式")
|
||||
loggedNonStandardJSON = true
|
||||
}
|
||||
putMap.putAll(it)
|
||||
}
|
||||
}
|
||||
return vRuleStr
|
||||
}
|
||||
|
||||
/**
|
||||
* 正则替换
|
||||
*/
|
||||
private fun replaceRegex(result: String, rule: SourceRule): String {
|
||||
if (rule.replaceRegex.isEmpty()) return result
|
||||
val replaceRegex = rule.replaceRegex
|
||||
val replacement = rule.replacement
|
||||
val regex = compileRegexCache(replaceRegex)
|
||||
if (rule.replaceFirst) {
|
||||
/* ##match##replace### 获取第一个匹配到的结果并进行替换 */
|
||||
if (regex != null) kotlin.runCatching {
|
||||
val pattern = regex.toPattern()
|
||||
val matcher = pattern.matcher(result)
|
||||
return if (matcher.find()) {
|
||||
matcher.group(0)!!.replaceFirst(regex, replacement)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
return replacement
|
||||
} else {
|
||||
/* ##match##replace 替换*/
|
||||
if (regex != null) kotlin.runCatching {
|
||||
return result.replace(regex, replacement)
|
||||
}
|
||||
return result.replace(replaceRegex, replacement)
|
||||
}
|
||||
}
|
||||
|
||||
private fun compileRegexCache(regex: String): Regex? {
|
||||
return regexCache.getOrPutLimit(regex, 16) {
|
||||
try {
|
||||
regex.toRegex()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getString 类规则缓存
|
||||
*/
|
||||
private fun splitSourceRuleCacheString(ruleStr: String?): List<SourceRule> {
|
||||
if (ruleStr.isNullOrEmpty()) return emptyList()
|
||||
return stringRuleCache.getOrPut(ruleStr) {
|
||||
splitSourceRule(ruleStr)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分解规则生成规则列表
|
||||
*/
|
||||
fun splitSourceRule(ruleStr: String?, allInOne: Boolean = false): List<SourceRule> {
|
||||
if (ruleStr.isNullOrEmpty()) return emptyList()
|
||||
val ruleList = ArrayList<SourceRule>()
|
||||
var mMode: Mode = Mode.Default
|
||||
var start = 0
|
||||
//仅首字符为:时为AllInOne,其实:与伪类选择器冲突,建议改成?更合理
|
||||
if (allInOne && ruleStr.startsWith(":")) {
|
||||
mMode = Mode.Regex
|
||||
isRegex = true
|
||||
start = 1
|
||||
} else if (isRegex) {
|
||||
mMode = Mode.Regex
|
||||
}
|
||||
var tmp: String
|
||||
val jsMatcher = JS_PATTERN.matcher(ruleStr)
|
||||
while (jsMatcher.find()) {
|
||||
if (jsMatcher.start() > start) {
|
||||
tmp = ruleStr.substring(start, jsMatcher.start()).trim { it <= ' ' }
|
||||
if (tmp.isNotEmpty()) {
|
||||
ruleList.add(SourceRule(tmp, mMode))
|
||||
}
|
||||
}
|
||||
ruleList.add(SourceRule(jsMatcher.group(2) ?: jsMatcher.group(1), Mode.Js))
|
||||
start = jsMatcher.end()
|
||||
}
|
||||
|
||||
if (ruleStr.length > start) {
|
||||
tmp = ruleStr.substring(start).trim { it <= ' ' }
|
||||
if (tmp.isNotEmpty()) {
|
||||
ruleList.add(SourceRule(tmp, mMode))
|
||||
}
|
||||
}
|
||||
|
||||
return ruleList
|
||||
}
|
||||
|
||||
private fun getOrCreateSingleSourceRule(rule: String): List<SourceRule> {
|
||||
return stringRuleCache.getOrPutLimit(rule, 16) {
|
||||
listOf(SourceRule(rule))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则类
|
||||
*/
|
||||
inner class SourceRule internal constructor(
|
||||
ruleStr: String,
|
||||
internal var mode: Mode = Mode.Default
|
||||
) {
|
||||
internal var rule: String
|
||||
internal var replaceRegex = ""
|
||||
internal var replacement = ""
|
||||
internal var replaceFirst = false
|
||||
internal val putMap = HashMap<String, String>()
|
||||
private val ruleParam = ArrayList<String>()
|
||||
private val ruleType = ArrayList<Int>()
|
||||
private val getRuleType = -2
|
||||
private val jsRuleType = -1
|
||||
private val defaultRuleType = 0
|
||||
|
||||
init {
|
||||
rule = when {
|
||||
mode == Mode.Js || mode == Mode.Regex -> ruleStr
|
||||
ruleStr.startsWith("@CSS:", true) -> {
|
||||
mode = Mode.Default
|
||||
ruleStr
|
||||
}
|
||||
|
||||
ruleStr.startsWith("@@") -> {
|
||||
mode = Mode.Default
|
||||
ruleStr.substring(2)
|
||||
}
|
||||
|
||||
ruleStr.startsWith("@XPath:", true) -> {
|
||||
mode = Mode.XPath
|
||||
ruleStr.substring(7)
|
||||
}
|
||||
|
||||
ruleStr.startsWith("@Json:", true) -> {
|
||||
mode = Mode.Json
|
||||
ruleStr.substring(6)
|
||||
}
|
||||
|
||||
isJSON || ruleStr.startsWith("$.") || ruleStr.startsWith("$[") -> {
|
||||
mode = Mode.Json
|
||||
ruleStr
|
||||
}
|
||||
|
||||
ruleStr.startsWith("/") -> {//XPath特征很明显,无需配置单独的识别标头
|
||||
mode = Mode.XPath
|
||||
ruleStr
|
||||
}
|
||||
|
||||
else -> ruleStr
|
||||
}
|
||||
//分离put
|
||||
rule = splitPutRule(rule, putMap)
|
||||
//@get,{{ }}, 拆分
|
||||
var start = 0
|
||||
var tmp: String
|
||||
val evalMatcher = evalPattern.matcher(rule)
|
||||
|
||||
if (evalMatcher.find()) {
|
||||
tmp = rule.substring(start, evalMatcher.start())
|
||||
if (mode != Mode.Js && mode != Mode.Regex &&
|
||||
(evalMatcher.start() == 0 || !tmp.contains("##"))
|
||||
) {
|
||||
mode = Mode.Regex
|
||||
}
|
||||
do {
|
||||
if (evalMatcher.start() > start) {
|
||||
tmp = rule.substring(start, evalMatcher.start())
|
||||
splitRegex(tmp)
|
||||
}
|
||||
tmp = evalMatcher.group()
|
||||
when {
|
||||
tmp.startsWith("@get:", true) -> {
|
||||
ruleType.add(getRuleType)
|
||||
ruleParam.add(tmp.substring(6, tmp.lastIndex))
|
||||
}
|
||||
|
||||
tmp.startsWith("{{") -> {
|
||||
ruleType.add(jsRuleType)
|
||||
ruleParam.add(tmp.substring(2, tmp.length - 2))
|
||||
}
|
||||
|
||||
else -> {
|
||||
splitRegex(tmp)
|
||||
}
|
||||
}
|
||||
start = evalMatcher.end()
|
||||
} while (evalMatcher.find())
|
||||
}
|
||||
if (rule.length > start) {
|
||||
tmp = rule.substring(start)
|
||||
splitRegex(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆分\$\d{1,2}
|
||||
*/
|
||||
private fun splitRegex(ruleStr: String) {
|
||||
var start = 0
|
||||
var tmp: String
|
||||
val ruleStrArray = ruleStr.split("##")
|
||||
val regexMatcher = regexPattern.matcher(ruleStrArray[0])
|
||||
|
||||
if (regexMatcher.find()) {
|
||||
if (mode != Mode.Js && mode != Mode.Regex) {
|
||||
mode = Mode.Regex
|
||||
}
|
||||
do {
|
||||
if (regexMatcher.start() > start) {
|
||||
tmp = ruleStr.substring(start, regexMatcher.start())
|
||||
ruleType.add(defaultRuleType)
|
||||
ruleParam.add(tmp)
|
||||
}
|
||||
tmp = regexMatcher.group()
|
||||
ruleType.add(tmp.substring(1).toInt())
|
||||
ruleParam.add(tmp)
|
||||
start = regexMatcher.end()
|
||||
} while (regexMatcher.find())
|
||||
}
|
||||
if (ruleStr.length > start) {
|
||||
tmp = ruleStr.substring(start)
|
||||
ruleType.add(defaultRuleType)
|
||||
ruleParam.add(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换@get,{{ }}
|
||||
*/
|
||||
fun makeUpRule(result: Any?) {
|
||||
val infoVal = StringBuilder()
|
||||
if (ruleParam.isNotEmpty()) {
|
||||
var index = ruleParam.size
|
||||
while (index-- > 0) {
|
||||
val regType = ruleType[index]
|
||||
when {
|
||||
regType > defaultRuleType -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(result as? List<String?>)?.run {
|
||||
if (this.size > regType) {
|
||||
this[regType]?.let {
|
||||
infoVal.insert(0, it)
|
||||
}
|
||||
}
|
||||
} ?: infoVal.insert(0, ruleParam[index])
|
||||
}
|
||||
|
||||
regType == jsRuleType -> {
|
||||
if (isRule(ruleParam[index])) {
|
||||
val ruleList = getOrCreateSingleSourceRule(ruleParam[index])
|
||||
getString(ruleList).let {
|
||||
infoVal.insert(0, it)
|
||||
}
|
||||
} else {
|
||||
val jsEval: Any? = evalJS(ruleParam[index], result)
|
||||
when {
|
||||
jsEval == null -> Unit
|
||||
jsEval is String -> infoVal.insert(0, jsEval)
|
||||
jsEval is Double && jsEval % 1.0 == 0.0 -> infoVal.insert(
|
||||
0,
|
||||
String.format(Locale.ROOT, "%.0f", jsEval)
|
||||
)
|
||||
|
||||
else -> infoVal.insert(0, jsEval.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
regType == getRuleType -> {
|
||||
infoVal.insert(0, get(ruleParam[index]))
|
||||
}
|
||||
|
||||
else -> infoVal.insert(0, ruleParam[index])
|
||||
}
|
||||
}
|
||||
rule = infoVal.toString()
|
||||
}
|
||||
//分离正则表达式
|
||||
val ruleStrS = rule.split("##")
|
||||
rule = ruleStrS[0].trim()
|
||||
if (ruleStrS.size > 1) {
|
||||
replaceRegex = ruleStrS[1]
|
||||
}
|
||||
if (ruleStrS.size > 2) {
|
||||
replacement = ruleStrS[2]
|
||||
}
|
||||
if (ruleStrS.size > 3) {
|
||||
replaceFirst = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRule(ruleStr: String): Boolean {
|
||||
return ruleStr.startsWith('@') //js首个字符不可能是@,除非是装饰器,所以@开头规定为规则
|
||||
|| ruleStr.startsWith("$.")
|
||||
|| ruleStr.startsWith("$[")
|
||||
|| ruleStr.startsWith("//")
|
||||
}
|
||||
|
||||
fun getParamSize(): Int {
|
||||
return ruleParam.size
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
XPath, Json, Default, Js, Regex
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据
|
||||
*/
|
||||
fun put(key: String, value: String): String {
|
||||
chapter?.putVariable(key, value)
|
||||
?: book?.putVariable(key, value)
|
||||
?: ruleData?.putVariable(key, value)
|
||||
?: source?.put(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存的数据
|
||||
*/
|
||||
fun get(key: String): String {
|
||||
when (key) {
|
||||
"bookName" -> book?.let {
|
||||
return it.name
|
||||
}
|
||||
|
||||
"title" -> chapter?.let {
|
||||
return it.title
|
||||
}
|
||||
}
|
||||
return chapter?.getVariable(key)?.takeIf { it.isNotEmpty() }
|
||||
?: book?.getVariable(key)?.takeIf { it.isNotEmpty() }
|
||||
?: ruleData?.getVariable(key)?.takeIf { it.isNotEmpty() }
|
||||
?: source?.get(key)?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行JS
|
||||
*/
|
||||
fun evalJS(jsStr: String, result: Any? = null): Any? {
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["java"] = this
|
||||
bindings["cookie"] = CookieStore
|
||||
bindings["cache"] = CacheManager
|
||||
bindings["source"] = source
|
||||
bindings["book"] = book
|
||||
bindings["result"] = result
|
||||
bindings["baseUrl"] = baseUrl
|
||||
bindings["chapter"] = chapter
|
||||
bindings["title"] = chapter?.title
|
||||
bindings["src"] = content
|
||||
bindings["nextChapterUrl"] = nextChapterUrl
|
||||
bindings["rssArticle"] = rssArticle
|
||||
}
|
||||
val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get()
|
||||
val scope = if (topScope == null) {
|
||||
RhinoScriptEngine.getRuntimeScope(bindings).apply {
|
||||
if (evalJSCallCount++ > 16) {
|
||||
topScopeRef = WeakReference(prototype)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bindings.apply {
|
||||
prototype = topScope
|
||||
}
|
||||
}
|
||||
val script = compileScriptCache(jsStr)
|
||||
val result = script.eval(scope, coroutineContext)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun compileScriptCache(jsStr: String): CompiledScript {
|
||||
return scriptCache.getOrPutLimit(jsStr, 16) {
|
||||
RhinoScriptEngine.compile(jsStr)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSource(): BaseSource? {
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* js实现跨域访问,不能删
|
||||
*/
|
||||
override fun ajax(url: Any): String? {
|
||||
val urlStr = if (url is List<*>) {
|
||||
url.firstOrNull().toString()
|
||||
} else {
|
||||
url.toString()
|
||||
}
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
urlStr,
|
||||
source = source,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
return kotlin.runCatching {
|
||||
analyzeUrl.getStrResponse().body
|
||||
}.onFailure {
|
||||
coroutineContext.ensureActive()
|
||||
log("ajax(${urlStr}) error\n${it.stackTraceToString()}")
|
||||
it.printOnDebug()
|
||||
}.getOrElse {
|
||||
it.stackTraceStr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新获取book
|
||||
*/
|
||||
fun reGetBook() {
|
||||
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
|
||||
val bookSource = source as? BookSource
|
||||
val book = book as? Book
|
||||
if (bookSource == null || book == null) return
|
||||
runBlocking(coroutineContext) {
|
||||
withTimeout(1800000) {
|
||||
WebBook.preciseSearchAwait(bookSource, book.name, book.author)
|
||||
.getOrThrow().let {
|
||||
book.bookUrl = it.bookUrl
|
||||
it.variableMap.forEach { entry ->
|
||||
book.putVariable(entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
WebBook.getBookInfoAwait(bookSource, book, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新tocUrl,有些书源目录url定期更新,可以在js调用更新
|
||||
*/
|
||||
fun refreshTocUrl() {
|
||||
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
|
||||
val bookSource = source as? BookSource
|
||||
val book = book as? Book
|
||||
if (bookSource == null || book == null) return
|
||||
runBlocking(coroutineContext) {
|
||||
withTimeout(1800000) {
|
||||
WebBook.getBookInfoAwait(bookSource, book, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE)
|
||||
private val evalPattern =
|
||||
Pattern.compile("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", Pattern.CASE_INSENSITIVE)
|
||||
private val regexPattern = Pattern.compile("\\$\\d{1,2}")
|
||||
|
||||
fun AnalyzeRule.setCoroutineContext(context: CoroutineContext): AnalyzeRule {
|
||||
coroutineContext = context.minusKey(ContinuationInterceptor)
|
||||
return this
|
||||
}
|
||||
|
||||
fun AnalyzeRule.setRuleData(ruleData: RuleDataInterface?): AnalyzeRule {
|
||||
this.ruleData = ruleData
|
||||
return this
|
||||
}
|
||||
|
||||
fun AnalyzeRule.setNextChapterUrl(nextChapterUrl: String?): AnalyzeRule {
|
||||
this.nextChapterUrl = nextChapterUrl
|
||||
return this
|
||||
}
|
||||
|
||||
fun AnalyzeRule.setChapter(chapter: BookChapter?): AnalyzeRule {
|
||||
this.chapter = chapter
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Base64
|
||||
import androidx.annotation.Keep
|
||||
import androidx.media3.common.MediaItem
|
||||
import cn.hutool.core.codec.PercentCodec
|
||||
import cn.hutool.core.net.RFC3986
|
||||
import cn.hutool.core.util.HexUtil
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.script.buildScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import io.legato.kazusa.constant.AppConst.UA_NAME
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.AppPattern.JS_PATTERN
|
||||
import io.legato.kazusa.constant.AppPattern.dataUriRegex
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.ConcurrentRateLimiter
|
||||
import io.legato.kazusa.help.JsExtensions
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.exoplayer.ExoPlayerHelper
|
||||
import io.legato.kazusa.help.glide.GlideHeaders
|
||||
import io.legato.kazusa.help.http.BackstageWebView
|
||||
import io.legato.kazusa.help.http.CookieManager
|
||||
import io.legato.kazusa.help.http.CookieManager.mergeCookies
|
||||
import io.legato.kazusa.help.http.CookieStore
|
||||
import io.legato.kazusa.help.http.RequestMethod
|
||||
import io.legato.kazusa.help.http.StrResponse
|
||||
import io.legato.kazusa.help.http.addHeaders
|
||||
import io.legato.kazusa.help.http.get
|
||||
import io.legato.kazusa.help.http.getProxyClient
|
||||
import io.legato.kazusa.help.http.newCallResponse
|
||||
import io.legato.kazusa.help.http.newCallStrResponse
|
||||
import io.legato.kazusa.help.http.postForm
|
||||
import io.legato.kazusa.help.http.postJson
|
||||
import io.legato.kazusa.help.http.postMultipart
|
||||
import io.legato.kazusa.help.source.getShareScope
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.utils.EncoderUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.GSONStrict
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.get
|
||||
import io.legato.kazusa.utils.isJson
|
||||
import io.legato.kazusa.utils.isJsonArray
|
||||
import io.legato.kazusa.utils.isJsonObject
|
||||
import io.legato.kazusa.utils.isXml
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.Charset
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.coroutines.ContinuationInterceptor
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Created by GKF on 2018/1/24.
|
||||
* 搜索URL规则解析
|
||||
*/
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
@Keep
|
||||
@SuppressLint("DefaultLocale")
|
||||
class AnalyzeUrl(
|
||||
private val mUrl: String,
|
||||
private val key: String? = null,
|
||||
private val page: Int? = null,
|
||||
private val speakText: String? = null,
|
||||
private val speakSpeed: Int? = null,
|
||||
private var baseUrl: String = "",
|
||||
private val source: BaseSource? = null,
|
||||
private val ruleData: RuleDataInterface? = null,
|
||||
private val chapter: BookChapter? = null,
|
||||
private val readTimeout: Long? = null,
|
||||
private val callTimeout: Long? = null,
|
||||
private var coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
headerMapF: Map<String, String>? = null,
|
||||
hasLoginHeader: Boolean = true
|
||||
) : JsExtensions {
|
||||
|
||||
var ruleUrl = ""
|
||||
private set
|
||||
var url: String = ""
|
||||
private set
|
||||
var type: String? = null
|
||||
private set
|
||||
val headerMap = LinkedHashMap<String, String>()
|
||||
private var body: String? = null
|
||||
private var urlNoQuery: String = ""
|
||||
private var encodedForm: String? = null
|
||||
private var encodedQuery: String? = null
|
||||
private var charset: String? = null
|
||||
private var method = RequestMethod.GET
|
||||
private var proxy: String? = null
|
||||
private var retry: Int = 0
|
||||
private var useWebView: Boolean = false
|
||||
private var webJs: String? = null
|
||||
private val enabledCookieJar = source?.enabledCookieJar == true
|
||||
private val domain: String
|
||||
private var webViewDelayTime: Long = 0
|
||||
private val concurrentRateLimiter = ConcurrentRateLimiter(source)
|
||||
|
||||
// 服务器ID
|
||||
var serverID: Long? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
coroutineContext = coroutineContext.minusKey(ContinuationInterceptor)
|
||||
val urlMatcher = paramPattern.matcher(baseUrl)
|
||||
if (urlMatcher.find()) baseUrl = baseUrl.substring(0, urlMatcher.start())
|
||||
(headerMapF ?: runScriptWithContext(coroutineContext) {
|
||||
source?.getHeaderMap(hasLoginHeader)
|
||||
})?.let {
|
||||
headerMap.putAll(it)
|
||||
if (it.containsKey("proxy")) {
|
||||
proxy = it["proxy"]
|
||||
headerMap.remove("proxy")
|
||||
}
|
||||
}
|
||||
initUrl()
|
||||
domain = NetworkUtils.getSubDomain(source?.getKey() ?: url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理url
|
||||
*/
|
||||
fun initUrl() {
|
||||
ruleUrl = mUrl
|
||||
//执行@js,<js></js>
|
||||
analyzeJs()
|
||||
//替换参数
|
||||
replaceKeyPageJs()
|
||||
//处理URL
|
||||
analyzeUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行@js,<js></js>
|
||||
*/
|
||||
private fun analyzeJs() {
|
||||
var start = 0
|
||||
val jsMatcher = JS_PATTERN.matcher(ruleUrl)
|
||||
var result = ruleUrl
|
||||
while (jsMatcher.find()) {
|
||||
if (jsMatcher.start() > start) {
|
||||
ruleUrl.substring(start, jsMatcher.start()).trim().let {
|
||||
if (it.isNotEmpty()) {
|
||||
result = it.replace("@result", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
result = evalJS(jsMatcher.group(2) ?: jsMatcher.group(1), result).toString()
|
||||
start = jsMatcher.end()
|
||||
}
|
||||
if (ruleUrl.length > start) {
|
||||
ruleUrl.substring(start).trim().let {
|
||||
if (it.isNotEmpty()) {
|
||||
result = it.replace("@result", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
ruleUrl = result
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换关键字,页数,JS
|
||||
*/
|
||||
private fun replaceKeyPageJs() { //先替换内嵌规则再替换页数规则,避免内嵌规则中存在大于小于号时,规则被切错
|
||||
//js
|
||||
if (ruleUrl.contains("{{") && ruleUrl.contains("}}")) {
|
||||
val analyze = RuleAnalyzer(ruleUrl) //创建解析
|
||||
//替换所有内嵌{{js}}
|
||||
val url = analyze.innerRule("{{", "}}") {
|
||||
val jsEval = evalJS(it) ?: ""
|
||||
when {
|
||||
jsEval is String -> jsEval
|
||||
jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
|
||||
else -> jsEval.toString()
|
||||
}
|
||||
}
|
||||
if (url.isNotEmpty()) ruleUrl = url
|
||||
}
|
||||
//page
|
||||
page?.let {
|
||||
val matcher = pagePattern.matcher(ruleUrl)
|
||||
while (matcher.find()) {
|
||||
val pages = matcher.group(1)!!.split(",")
|
||||
ruleUrl = if (page < pages.size) { //pages[pages.size - 1]等同于pages.last()
|
||||
ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' })
|
||||
} else {
|
||||
ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Url
|
||||
*/
|
||||
private fun analyzeUrl() {
|
||||
//replaceKeyPageJs已经替换掉额外内容,此处url是基础形式,可以直接切首个‘,’之前字符串。
|
||||
val urlMatcher = paramPattern.matcher(ruleUrl)
|
||||
val urlNoOption =
|
||||
if (urlMatcher.find()) ruleUrl.substring(0, urlMatcher.start()) else ruleUrl
|
||||
url = NetworkUtils.getAbsoluteURL(baseUrl, urlNoOption)
|
||||
NetworkUtils.getBaseUrl(url)?.let {
|
||||
baseUrl = it
|
||||
}
|
||||
if (urlNoOption.length != ruleUrl.length) {
|
||||
val urlOptionStr = ruleUrl.substring(urlMatcher.end())
|
||||
var urlOption = GSONStrict.fromJsonObject<UrlOption>(urlOptionStr).getOrNull()
|
||||
if (urlOption == null) {
|
||||
urlOption = GSON.fromJsonObject<UrlOption>(urlOptionStr).getOrNull()
|
||||
if (urlOption != null) {
|
||||
Debug.log("≡链接参数 JSON 格式不规范,请改为规范格式")
|
||||
}
|
||||
}
|
||||
urlOption?.let { option ->
|
||||
option.getMethod()?.let {
|
||||
if (it.equals("POST", true)) method = RequestMethod.POST
|
||||
}
|
||||
option.getHeaderMap()?.forEach { entry ->
|
||||
headerMap[entry.key.toString()] = entry.value.toString()
|
||||
}
|
||||
option.getBody()?.let {
|
||||
body = it
|
||||
}
|
||||
type = option.getType()
|
||||
charset = option.getCharset()
|
||||
retry = option.getRetry()
|
||||
useWebView = option.useWebView()
|
||||
webJs = option.getWebJs()
|
||||
option.getJs()?.let { jsStr ->
|
||||
evalJS(jsStr, url)?.toString()?.let {
|
||||
url = it
|
||||
}
|
||||
}
|
||||
serverID = option.getServerID()
|
||||
webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
|
||||
}
|
||||
}
|
||||
urlNoQuery = url
|
||||
when (method) {
|
||||
RequestMethod.GET -> {
|
||||
val pos = url.indexOf('?')
|
||||
if (pos != -1) {
|
||||
analyzeQuery(url.substring(pos + 1))
|
||||
urlNoQuery = url.substring(0, pos)
|
||||
}
|
||||
}
|
||||
|
||||
RequestMethod.POST -> body?.let {
|
||||
if (!it.isJson() && !it.isXml() && headerMap["Content-Type"].isNullOrEmpty()) {
|
||||
analyzeFields(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析QueryMap <key>=<value>
|
||||
* name=
|
||||
* name=name
|
||||
* name=<BASE64> eg name=bmFtZQ==
|
||||
*/
|
||||
private fun analyzeFields(fieldsTxt: String) {
|
||||
encodedForm = encodeParams(fieldsTxt, charset, false)
|
||||
}
|
||||
|
||||
private fun analyzeQuery(query: String) {
|
||||
encodedQuery = encodeParams(query, charset, true)
|
||||
}
|
||||
|
||||
private fun encodeParams(params: String, charset: String?, isQuery: Boolean): String {
|
||||
val checkEncoded = charset.isNullOrEmpty()
|
||||
val charset = when {
|
||||
charset.isNullOrEmpty() -> Charsets.UTF_8
|
||||
charset == "escape" -> null
|
||||
else -> charset(charset)
|
||||
}
|
||||
if (isQuery && charset != null) {
|
||||
if (NetworkUtils.encodedQuery(params)) {
|
||||
return params
|
||||
}
|
||||
return queryEncoder.encode(params, charset)
|
||||
}
|
||||
val len = params.length
|
||||
val sb = StringBuilder()
|
||||
var pos = 0
|
||||
while (pos <= len) {
|
||||
if (sb.isNotEmpty()) {
|
||||
sb.append("&")
|
||||
}
|
||||
var ampOffset = params.indexOf("&", pos)
|
||||
if (ampOffset == -1) {
|
||||
ampOffset = len
|
||||
}
|
||||
val eqOffset = params.indexOf("=", pos)
|
||||
val key: String
|
||||
val value: String?
|
||||
if (eqOffset == -1 || eqOffset > ampOffset) {
|
||||
key = params.substring(pos, ampOffset)
|
||||
value = null
|
||||
} else {
|
||||
key = params.substring(pos, eqOffset)
|
||||
value = params.substring(eqOffset + 1, ampOffset)
|
||||
}
|
||||
sb.appendEncoded(key, checkEncoded, charset)
|
||||
if (value != null) {
|
||||
sb.append("=")
|
||||
sb.appendEncoded(value, checkEncoded, charset)
|
||||
}
|
||||
pos = ampOffset + 1
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendEncoded(
|
||||
value: String,
|
||||
checkEncoded: Boolean,
|
||||
charset: Charset?
|
||||
) {
|
||||
if (checkEncoded && NetworkUtils.encodedForm(value)) {
|
||||
append(value)
|
||||
} else if (charset == null) {
|
||||
append(EncoderUtils.escape(value))
|
||||
} else {
|
||||
append(URLEncoder.encode(value, charset))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行JS
|
||||
*/
|
||||
fun evalJS(jsStr: String, result: Any? = null): Any? {
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["java"] = this
|
||||
bindings["baseUrl"] = baseUrl
|
||||
bindings["cookie"] = CookieStore
|
||||
bindings["cache"] = CacheManager
|
||||
bindings["page"] = page
|
||||
bindings["key"] = key
|
||||
bindings["speakText"] = speakText
|
||||
bindings["speakSpeed"] = speakSpeed
|
||||
bindings["book"] = ruleData as? Book
|
||||
bindings["source"] = source
|
||||
bindings["result"] = result
|
||||
}
|
||||
val sharedScope = source?.getShareScope(coroutineContext)
|
||||
val scope = if (sharedScope == null) {
|
||||
RhinoScriptEngine.getRuntimeScope(bindings)
|
||||
} else {
|
||||
bindings.apply {
|
||||
prototype = sharedScope
|
||||
}
|
||||
}
|
||||
return RhinoScriptEngine.eval(jsStr, scope, coroutineContext)
|
||||
}
|
||||
|
||||
fun put(key: String, value: String): String {
|
||||
chapter?.putVariable(key, value)
|
||||
?: ruleData?.putVariable(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
fun get(key: String): String {
|
||||
when (key) {
|
||||
"bookName" -> (ruleData as? Book)?.let {
|
||||
return it.name
|
||||
}
|
||||
|
||||
"title" -> chapter?.let {
|
||||
return it.title
|
||||
}
|
||||
}
|
||||
return chapter?.getVariable(key)?.takeIf { it.isNotEmpty() }
|
||||
?: ruleData?.getVariable(key)?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 访问网站,返回StrResponse
|
||||
*/
|
||||
suspend fun getStrResponseAwait(
|
||||
jsStr: String? = null,
|
||||
sourceRegex: String? = null,
|
||||
useWebView: Boolean = true,
|
||||
): StrResponse {
|
||||
if (type != null) {
|
||||
return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
|
||||
}
|
||||
concurrentRateLimiter.withLimit {
|
||||
setCookie()
|
||||
val strResponse: StrResponse
|
||||
if (this.useWebView && useWebView) {
|
||||
strResponse = when (method) {
|
||||
RequestMethod.POST -> {
|
||||
val res = getClient().newCallStrResponse(retry) {
|
||||
addHeaders(headerMap)
|
||||
url(urlNoQuery)
|
||||
if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) {
|
||||
postForm(encodedForm ?: "")
|
||||
} else {
|
||||
postJson(body)
|
||||
}
|
||||
}
|
||||
BackstageWebView(
|
||||
url = res.url,
|
||||
html = res.body,
|
||||
tag = source?.getKey(),
|
||||
javaScript = webJs ?: jsStr,
|
||||
sourceRegex = sourceRegex,
|
||||
headerMap = headerMap,
|
||||
delayTime = webViewDelayTime
|
||||
).getStrResponse()
|
||||
}
|
||||
|
||||
else -> BackstageWebView(
|
||||
url = url,
|
||||
tag = source?.getKey(),
|
||||
javaScript = webJs ?: jsStr,
|
||||
sourceRegex = sourceRegex,
|
||||
headerMap = headerMap,
|
||||
delayTime = webViewDelayTime
|
||||
).getStrResponse()
|
||||
}
|
||||
} else {
|
||||
strResponse = getClient().newCallStrResponse(retry) {
|
||||
addHeaders(headerMap)
|
||||
when (method) {
|
||||
RequestMethod.POST -> {
|
||||
url(urlNoQuery)
|
||||
val contentType = headerMap["Content-Type"]
|
||||
val body = body
|
||||
if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) {
|
||||
postForm(encodedForm ?: "")
|
||||
} else if (!contentType.isNullOrBlank()) {
|
||||
val requestBody = body.toRequestBody(contentType.toMediaType())
|
||||
post(requestBody)
|
||||
} else {
|
||||
postJson(body)
|
||||
}
|
||||
}
|
||||
|
||||
else -> get(urlNoQuery, encodedQuery)
|
||||
}
|
||||
}.let {
|
||||
val isXml = it.raw.body?.contentType()?.toString()
|
||||
?.matches(AppPattern.xmlContentTypeRegex) == true
|
||||
if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) {
|
||||
StrResponse(it.raw, "<?xml version=\"1.0\"?>" + it.body)
|
||||
} else it
|
||||
}
|
||||
}
|
||||
return strResponse
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun getStrResponse(
|
||||
jsStr: String? = null,
|
||||
sourceRegex: String? = null,
|
||||
useWebView: Boolean = true,
|
||||
): StrResponse {
|
||||
return runBlocking(coroutineContext) {
|
||||
getStrResponseAwait(jsStr, sourceRegex, useWebView)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 访问网站,返回Response
|
||||
*/
|
||||
suspend fun getResponseAwait(): Response {
|
||||
concurrentRateLimiter.withLimit {
|
||||
setCookie()
|
||||
val response = getClient().newCallResponse(retry) {
|
||||
addHeaders(headerMap)
|
||||
when (method) {
|
||||
RequestMethod.POST -> {
|
||||
url(urlNoQuery)
|
||||
val contentType = headerMap["Content-Type"]
|
||||
val body = body
|
||||
if (!encodedForm.isNullOrEmpty() || body.isNullOrBlank()) {
|
||||
postForm(encodedForm ?: "")
|
||||
} else if (!contentType.isNullOrBlank()) {
|
||||
val requestBody = body.toRequestBody(contentType.toMediaType())
|
||||
post(requestBody)
|
||||
} else {
|
||||
postJson(body)
|
||||
}
|
||||
}
|
||||
|
||||
else -> get(urlNoQuery, encodedQuery)
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private fun getClient(): OkHttpClient {
|
||||
val client = getProxyClient(proxy)
|
||||
if (readTimeout == null && callTimeout == null) {
|
||||
return client
|
||||
}
|
||||
return client.newBuilder().run {
|
||||
if (readTimeout != null) {
|
||||
readTimeout(readTimeout, TimeUnit.MILLISECONDS)
|
||||
callTimeout(max(60 * 1000L, readTimeout * 2), TimeUnit.MILLISECONDS)
|
||||
}
|
||||
if (callTimeout != null) {
|
||||
callTimeout(callTimeout, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
fun getResponse(): Response {
|
||||
return runBlocking(coroutineContext) {
|
||||
getResponseAwait()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getByteArrayIfDataUri(): ByteArray? {
|
||||
if (!urlNoQuery.startsWith("data:")) {
|
||||
return null
|
||||
}
|
||||
val dataUriFindResult = dataUriRegex.find(urlNoQuery)
|
||||
if (dataUriFindResult != null) {
|
||||
val dataUriBase64 = dataUriFindResult.groupValues[1]
|
||||
val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
|
||||
return byteArray
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 访问网站,返回ByteArray
|
||||
*/
|
||||
suspend fun getByteArrayAwait(): ByteArray {
|
||||
getByteArrayIfDataUri()?.let {
|
||||
return it
|
||||
}
|
||||
return getResponseAwait().body!!.bytes()
|
||||
}
|
||||
|
||||
fun getByteArray(): ByteArray {
|
||||
return runBlocking(coroutineContext) {
|
||||
getByteArrayAwait()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 访问网站,返回InputStream
|
||||
*/
|
||||
suspend fun getInputStreamAwait(): InputStream {
|
||||
getByteArrayIfDataUri()?.let {
|
||||
return ByteArrayInputStream(it)
|
||||
}
|
||||
return getResponseAwait().body!!.byteStream()
|
||||
}
|
||||
|
||||
fun getInputStream(): InputStream {
|
||||
return runBlocking(coroutineContext) {
|
||||
getInputStreamAwait()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
suspend fun upload(fileName: String, file: Any, contentType: String): StrResponse {
|
||||
return getProxyClient(proxy).newCallStrResponse(retry) {
|
||||
url(urlNoQuery)
|
||||
val bodyMap = GSON.fromJsonObject<HashMap<String, Any>>(body).getOrNull()!!
|
||||
bodyMap.forEach { entry ->
|
||||
if (entry.value.toString() == "fileRequest") {
|
||||
bodyMap[entry.key] = mapOf(
|
||||
Pair("fileName", fileName),
|
||||
Pair("file", file),
|
||||
Pair("contentType", contentType)
|
||||
)
|
||||
}
|
||||
}
|
||||
postMultipart(type, bodyMap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置cookie 优先级
|
||||
* urlOption临时cookie > 数据库cookie
|
||||
*/
|
||||
private fun setCookie() {
|
||||
val cookie = kotlin.run {
|
||||
/* 每次调用getXX cookieJar已经保存过了
|
||||
if (enabledCookieJar) {
|
||||
val key = "${domain}_cookieJar"
|
||||
CacheManager.getFromMemory(key)?.let {
|
||||
return@run it
|
||||
}
|
||||
}
|
||||
*/
|
||||
CookieStore.getCookie(domain)
|
||||
}
|
||||
if (cookie.isNotEmpty()) {
|
||||
mergeCookies(cookie, headerMap["Cookie"])?.let {
|
||||
headerMap.put("Cookie", it)
|
||||
}
|
||||
}
|
||||
if (enabledCookieJar) {
|
||||
headerMap[CookieManager.cookieJarHeader] = "1"
|
||||
} else {
|
||||
headerMap.remove(CookieManager.cookieJarHeader)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存cookieJar中的cookie在访问结束时就保存,不等到下次访问
|
||||
*/
|
||||
private fun saveCookie() {
|
||||
//书源启用保存cookie时 添加内存中的cookie到数据库
|
||||
if (enabledCookieJar) {
|
||||
val key = "${domain}_cookieJar"
|
||||
CacheManager.getFromMemory(key)?.let {
|
||||
if (it is String) {
|
||||
CookieStore.replaceCookie(domain, it)
|
||||
CacheManager.deleteMemory(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*获取处理过阅读定义的urlOption和cookie的GlideUrl
|
||||
*/
|
||||
fun getGlideUrl(): GlideUrl {
|
||||
setCookie()
|
||||
return GlideUrl(url, GlideHeaders(headerMap))
|
||||
}
|
||||
|
||||
fun getUserAgent(): String {
|
||||
return headerMap.get(UA_NAME, true) ?: AppConfig.userAgent
|
||||
}
|
||||
|
||||
fun isPost(): Boolean {
|
||||
return method == RequestMethod.POST
|
||||
}
|
||||
|
||||
override fun getSource(): BaseSource? {
|
||||
return source
|
||||
}
|
||||
|
||||
companion object {
|
||||
val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
|
||||
private val pagePattern = Pattern.compile("<(.*?)>")
|
||||
private val queryEncoder =
|
||||
RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}"))
|
||||
|
||||
fun AnalyzeUrl.getMediaItem(): MediaItem {
|
||||
setCookie()
|
||||
return ExoPlayerHelper.createMediaItem(url, headerMap)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class UrlOption(
|
||||
private var method: String? = null,
|
||||
private var charset: String? = null,
|
||||
private var headers: Any? = null,
|
||||
private var body: Any? = null,
|
||||
/**
|
||||
* 源Url
|
||||
**/
|
||||
private var origin: String? = null,
|
||||
/**
|
||||
* 重试次数
|
||||
**/
|
||||
private var retry: Int? = null,
|
||||
/**
|
||||
* 类型
|
||||
**/
|
||||
private var type: String? = null,
|
||||
/**
|
||||
* 是否使用webView
|
||||
**/
|
||||
private var webView: Any? = null,
|
||||
/**
|
||||
* webView中执行的js
|
||||
**/
|
||||
private var webJs: String? = null,
|
||||
/**
|
||||
* 解析完url参数时执行的js
|
||||
* 执行结果会赋值给url
|
||||
*/
|
||||
private var js: String? = null,
|
||||
/**
|
||||
* 服务器id
|
||||
*/
|
||||
private var serverID: Long? = null,
|
||||
/**
|
||||
* webview等待页面加载完毕的延迟时间(毫秒)
|
||||
*/
|
||||
private var webViewDelayTime: Long? = null,
|
||||
) {
|
||||
fun setMethod(value: String?) {
|
||||
method = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getMethod(): String? {
|
||||
return method
|
||||
}
|
||||
|
||||
fun setCharset(value: String?) {
|
||||
charset = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getCharset(): String? {
|
||||
return charset
|
||||
}
|
||||
|
||||
fun setOrigin(value: String?) {
|
||||
origin = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getOrigin(): String? {
|
||||
return origin
|
||||
}
|
||||
|
||||
fun setRetry(value: String?) {
|
||||
retry = if (value.isNullOrEmpty()) null else value.toIntOrNull()
|
||||
}
|
||||
|
||||
fun getRetry(): Int {
|
||||
return retry ?: 0
|
||||
}
|
||||
|
||||
fun setType(value: String?) {
|
||||
type = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getType(): String? {
|
||||
return type
|
||||
}
|
||||
|
||||
fun useWebView(): Boolean {
|
||||
return when (webView) {
|
||||
null, "", false, "false" -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun useWebView(boolean: Boolean) {
|
||||
webView = if (boolean) true else null
|
||||
}
|
||||
|
||||
fun setHeaders(value: String?) {
|
||||
headers = if (value.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
GSON.fromJsonObject<Map<String, Any>>(value).getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHeaderMap(): Map<*, *>? {
|
||||
return when (val value = headers) {
|
||||
is Map<*, *> -> value
|
||||
is String -> GSON.fromJsonObject<Map<String, Any>>(value).getOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun setBody(value: String?) {
|
||||
body = when {
|
||||
value.isNullOrBlank() -> null
|
||||
value.isJsonObject() -> GSON.fromJsonObject<Map<String, Any>>(value).getOrNull()
|
||||
value.isJsonArray() -> GSON.fromJsonArray<Map<String, Any>>(value).getOrNull()
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
fun getBody(): String? {
|
||||
return body?.let {
|
||||
it as? String ?: GSON.toJson(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWebJs(value: String?) {
|
||||
webJs = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getWebJs(): String? {
|
||||
return webJs
|
||||
}
|
||||
|
||||
fun setJs(value: String?) {
|
||||
js = if (value.isNullOrBlank()) null else value
|
||||
}
|
||||
|
||||
fun getJs(): String? {
|
||||
return js
|
||||
}
|
||||
|
||||
fun setServerID(value: String?) {
|
||||
serverID = if (value.isNullOrBlank()) null else value.toLong()
|
||||
}
|
||||
|
||||
fun getServerID(): Long? {
|
||||
return serverID
|
||||
}
|
||||
|
||||
fun setWebViewDelayTime(value: String?) {
|
||||
webViewDelayTime = if (value.isNullOrBlank()) null else value.toLong()
|
||||
}
|
||||
|
||||
fun getWebViewDelayTime(): Long? {
|
||||
return webViewDelayTime
|
||||
}
|
||||
}
|
||||
|
||||
data class ConcurrentRecord(
|
||||
/**
|
||||
* 是否按频率
|
||||
*/
|
||||
val isConcurrent: Boolean,
|
||||
/**
|
||||
* 开始访问时间
|
||||
*/
|
||||
var time: Long,
|
||||
/**
|
||||
* 正在访问的个数
|
||||
*/
|
||||
var frequency: Int
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
|
||||
@Suppress("unused")
|
||||
class CustomUrl(url: String) {
|
||||
|
||||
private val mUrl: String
|
||||
private val attribute = hashMapOf<String, Any>()
|
||||
|
||||
init {
|
||||
val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
|
||||
mUrl = if (urlMatcher.find()) {
|
||||
val attr = url.substring(urlMatcher.end())
|
||||
GSON.fromJsonObject<Map<String, Any>>(attr).getOrNull()?.let {
|
||||
attribute.putAll(it)
|
||||
}
|
||||
url.substring(0, urlMatcher.start())
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
fun putAttribute(key: String, value: Any?): CustomUrl {
|
||||
if (value == null) {
|
||||
attribute.remove(key)
|
||||
} else {
|
||||
attribute[key] = value
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun getUrl(): String {
|
||||
return mUrl
|
||||
}
|
||||
|
||||
fun getAttr(): Map<String, Any> {
|
||||
return attribute
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
if (attribute.isEmpty()) {
|
||||
return mUrl
|
||||
}
|
||||
return mUrl + "," + GSON.toJson(attribute)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,378 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
//通用的规则切分处理
|
||||
class RuleAnalyzer(data: String, code: Boolean = false) {
|
||||
|
||||
private var queue: String = data //被处理字符串
|
||||
private var pos = 0 //当前处理到的位置
|
||||
private var start = 0 //当前处理字段的开始
|
||||
private var startX = 0 //当前规则的开始
|
||||
|
||||
private var rule = ArrayList<String>() //分割出的规则列表
|
||||
private var step: Int = 0 //分割字符的长度
|
||||
var elementsType = "" //当前分割字符串
|
||||
|
||||
fun trim() { // 修剪当前规则之前的"@"或者空白符
|
||||
if (queue[pos] == '@' || queue[pos] < '!') { //在while里重复设置start和startX会拖慢执行速度,所以先来个判断是否存在需要修剪的字段,最后再一次性设置start和startX
|
||||
pos++
|
||||
while (queue[pos] == '@' || queue[pos] < '!') pos++
|
||||
start = pos //开始点推移
|
||||
startX = pos //规则起始点推移
|
||||
}
|
||||
}
|
||||
|
||||
//将pos重置为0,方便复用
|
||||
fun reSetPos() {
|
||||
pos = 0
|
||||
startX = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 从剩余字串中拉出一个字符串,直到但不包括匹配序列
|
||||
* @param seq 查找的字符串 **区分大小写**
|
||||
* @return 是否找到相应字段。
|
||||
*/
|
||||
private fun consumeTo(seq: String): Boolean {
|
||||
start = pos //将处理到的位置设置为规则起点
|
||||
val offset = queue.indexOf(seq, pos)
|
||||
return if (offset != -1) {
|
||||
pos = offset
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。
|
||||
* @param seq 匹配字符串序列
|
||||
* @return 成功返回true并设置间隔,失败则直接返回fasle
|
||||
*/
|
||||
private fun consumeToAny(vararg seq: String): Boolean {
|
||||
|
||||
var pos = pos //声明新变量记录匹配位置,不更改类本身的位置
|
||||
|
||||
while (pos != queue.length) {
|
||||
|
||||
for (s in seq) {
|
||||
if (queue.regionMatches(pos, s, 0, s.length)) {
|
||||
step = s.length //间隔数
|
||||
this.pos = pos //匹配成功, 同步处理位置到类
|
||||
return true //匹配就返回 true
|
||||
}
|
||||
}
|
||||
|
||||
pos++ //逐个试探
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。
|
||||
* @param seq 匹配字符序列
|
||||
* @return 返回匹配位置
|
||||
*/
|
||||
private fun findToAny(vararg seq: Char): Int {
|
||||
|
||||
var pos = pos //声明新变量记录匹配位置,不更改类本身的位置
|
||||
|
||||
while (pos != queue.length) {
|
||||
|
||||
for (s in seq) if (queue[pos] == s) return pos //匹配则返回位置
|
||||
|
||||
pos++ //逐个试探
|
||||
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉出一个非内嵌代码平衡组,存在转义文本
|
||||
*/
|
||||
private fun chompCodeBalanced(open: Char, close: Char): Boolean {
|
||||
|
||||
var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos
|
||||
|
||||
var depth = 0 //嵌套深度
|
||||
var otherDepth = 0 //其他对称符合嵌套深度
|
||||
|
||||
var inSingleQuote = false //单引号
|
||||
var inDoubleQuote = false //双引号
|
||||
|
||||
do {
|
||||
if (pos == queue.length) break
|
||||
val c = queue[pos++]
|
||||
if (c != ESC) { //非转义字符
|
||||
if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号
|
||||
else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号
|
||||
|
||||
if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环
|
||||
|
||||
if (c == '[') depth++ //开始嵌套一层
|
||||
else if (c == ']') depth-- //闭合一层嵌套
|
||||
else if (depth == 0) {
|
||||
//处于默认嵌套中的非默认字符不需要平衡,仅depth为0时默认嵌套全部闭合,此字符才进行嵌套
|
||||
if (c == open) otherDepth++
|
||||
else if (c == close) otherDepth--
|
||||
}
|
||||
|
||||
} else pos++
|
||||
|
||||
} while (depth > 0 || otherDepth > 0) //拉出一个平衡字串
|
||||
|
||||
return if (depth > 0 || otherDepth > 0) false else {
|
||||
this.pos = pos //同步位置
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉出一个规则平衡组,经过仔细测试xpath和jsoup中,引号内转义字符无效。
|
||||
*/
|
||||
private fun chompRuleBalanced(open: Char, close: Char): Boolean {
|
||||
|
||||
var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos
|
||||
var depth = 0 //嵌套深度
|
||||
var inSingleQuote = false //单引号
|
||||
var inDoubleQuote = false //双引号
|
||||
|
||||
do {
|
||||
if (pos == queue.length) break
|
||||
val c = queue[pos++]
|
||||
if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号
|
||||
else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号
|
||||
|
||||
if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环
|
||||
else if (c == '\\') { //不在引号中的转义字符才将下个字符转义
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
|
||||
if (c == open) depth++ //开始嵌套一层
|
||||
else if (c == close) depth-- //闭合一层嵌套
|
||||
|
||||
} while (depth > 0) //拉出一个平衡字串
|
||||
|
||||
return if (depth > 0) false else {
|
||||
this.pos = pos //同步位置
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 不用正则,不到最后不切片也不用中间变量存储,只在序列中标记当前查找字段的开头结尾,到返回时才切片,高效快速准确切割规则
|
||||
* 解决jsonPath自带的"&&"和"||"与阅读的规则冲突,以及规则正则或字符串中包含"&&"、"||"、"%%"、"@"导致的冲突
|
||||
*/
|
||||
tailrec fun splitRule(vararg split: String): ArrayList<String> { //首段匹配,elementsType为空
|
||||
|
||||
if (split.size == 1) {
|
||||
elementsType = split[0] //设置分割字串
|
||||
return if (!consumeTo(elementsType)) {
|
||||
rule += queue.substring(startX)
|
||||
rule
|
||||
} else {
|
||||
step = elementsType.length //设置分隔符长度
|
||||
splitRule()
|
||||
} //递归匹配
|
||||
} else if (!consumeToAny(* split)) { //未找到分隔符
|
||||
rule += queue.substring(startX)
|
||||
return rule
|
||||
}
|
||||
|
||||
val end = pos //记录分隔位置
|
||||
pos = start //重回开始,启动另一种查找
|
||||
|
||||
do {
|
||||
val st = findToAny('[', '(') //查找筛选器位置
|
||||
|
||||
if (st == -1) {
|
||||
|
||||
rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
|
||||
|
||||
elementsType = queue.substring(end, end + step) //设置组合类型
|
||||
pos = end + step //跳过分隔符
|
||||
|
||||
while (consumeTo(elementsType)) { //循环切分规则压入数组
|
||||
rule += queue.substring(start, pos)
|
||||
pos += step //跳过分隔符
|
||||
}
|
||||
|
||||
rule += queue.substring(pos) //将剩余字段压入数组末尾
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组
|
||||
|
||||
rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
|
||||
|
||||
elementsType = queue.substring(end, end + step) //设置组合类型
|
||||
pos = end + step //跳过分隔符
|
||||
|
||||
while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组
|
||||
rule += queue.substring(start, pos)
|
||||
pos += step //跳过分隔符
|
||||
}
|
||||
|
||||
return if (pos > st) {
|
||||
startX = start
|
||||
splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配
|
||||
} else { //执行到此,证明后面再无分隔字符
|
||||
rule += queue.substring(pos) //将剩余字段压入数组末尾
|
||||
rule
|
||||
}
|
||||
}
|
||||
|
||||
pos = st //位置推移到筛选器处
|
||||
val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符
|
||||
|
||||
if (!chompBalanced(queue[pos], next)) throw Error(
|
||||
queue.substring(0, start) + "后未平衡"
|
||||
) //拉出一个筛选器,不平衡则报错
|
||||
|
||||
} while (end > pos)
|
||||
|
||||
start = pos //设置开始查找筛选器位置的起始位置
|
||||
|
||||
return splitRule(* split) //递归调用首段匹配
|
||||
}
|
||||
|
||||
@JvmName("splitRuleNext")
|
||||
private tailrec fun splitRule(): ArrayList<String> { //二段匹配被调用,elementsType非空(已在首段赋值),直接按elementsType查找,比首段采用的方式更快
|
||||
|
||||
val end = pos //记录分隔位置
|
||||
pos = start //重回开始,启动另一种查找
|
||||
|
||||
do {
|
||||
val st = findToAny('[', '(') //查找筛选器位置
|
||||
|
||||
if (st == -1) {
|
||||
|
||||
rule += arrayOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
|
||||
pos = end + step //跳过分隔符
|
||||
|
||||
while (consumeTo(elementsType)) { //循环切分规则压入数组
|
||||
rule += queue.substring(start, pos)
|
||||
pos += step //跳过分隔符
|
||||
}
|
||||
|
||||
rule += queue.substring(pos) //将剩余字段压入数组末尾
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组
|
||||
|
||||
rule += arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
|
||||
pos = end + step //跳过分隔符
|
||||
|
||||
while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组
|
||||
rule += queue.substring(start, pos)
|
||||
pos += step //跳过分隔符
|
||||
}
|
||||
|
||||
return if (pos > st) {
|
||||
startX = start
|
||||
splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配
|
||||
} else { //执行到此,证明后面再无分隔字符
|
||||
rule += queue.substring(pos) //将剩余字段压入数组末尾
|
||||
rule
|
||||
}
|
||||
}
|
||||
|
||||
pos = st //位置推移到筛选器处
|
||||
val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符
|
||||
|
||||
if (!chompBalanced(queue[pos], next)) throw Error(
|
||||
queue.substring(0, start) + "后未平衡"
|
||||
) //拉出一个筛选器,不平衡则报错
|
||||
|
||||
} while (end > pos)
|
||||
|
||||
start = pos //设置开始查找筛选器位置的起始位置
|
||||
|
||||
return if (!consumeTo(elementsType)) {
|
||||
rule += queue.substring(startX)
|
||||
rule
|
||||
} else splitRule() //递归匹配
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换内嵌规则
|
||||
* @param inner 起始标志,如{$.
|
||||
* @param startStep 不属于规则部分的前置字符长度,如{$.中{不属于规则的组成部分,故startStep为1
|
||||
* @param endStep 不属于规则部分的后置字符长度
|
||||
* @param fr 查找到内嵌规则时,用于解析的函数
|
||||
*
|
||||
* */
|
||||
fun innerRule(
|
||||
inner: String,
|
||||
startStep: Int = 1,
|
||||
endStep: Int = 1,
|
||||
fr: (String) -> String?
|
||||
): String {
|
||||
val st = StringBuilder()
|
||||
|
||||
while (consumeTo(inner)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true
|
||||
val posPre = pos //记录consumeTo匹配位置
|
||||
if (chompCodeBalanced('{', '}')) {
|
||||
val frv = fr(queue.substring(posPre + startStep, pos - endStep))
|
||||
if (!frv.isNullOrEmpty()) {
|
||||
st.append(queue.substring(startX, posPre) + frv) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串
|
||||
startX = pos //记录下次规则起点
|
||||
continue //获取内容成功,继续选择下个内嵌规则
|
||||
}
|
||||
}
|
||||
pos += inner.length //拉出字段不平衡,inner只是个普通字串,跳到此inner后继续匹配
|
||||
}
|
||||
|
||||
return if (startX == 0) "" else st.apply {
|
||||
append(queue.substring(startX))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换内嵌规则
|
||||
* @param fr 查找到内嵌规则时,用于解析的函数
|
||||
*
|
||||
* */
|
||||
fun innerRule(
|
||||
startStr: String,
|
||||
endStr: String,
|
||||
fr: (String) -> String?
|
||||
): String {
|
||||
|
||||
val st = StringBuilder()
|
||||
while (consumeTo(startStr)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true
|
||||
pos += startStr.length //跳过开始字符串
|
||||
val posPre = pos //记录consumeTo匹配位置
|
||||
if (consumeTo(endStr)) {
|
||||
val frv = fr(queue.substring(posPre, pos))
|
||||
st.append(
|
||||
queue.substring(
|
||||
startX,
|
||||
posPre - startStr.length
|
||||
) + frv
|
||||
) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串
|
||||
pos += endStr.length //跳过结束字符串
|
||||
startX = pos //记录下次规则起点
|
||||
}
|
||||
}
|
||||
|
||||
return if (startX == 0) queue else st.apply {
|
||||
append(queue.substring(startX))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
//设置平衡组函数,json或JavaScript时设置成chompCodeBalanced,否则为chompRuleBalanced
|
||||
val chompBalanced = if (code) ::chompCodeBalanced else ::chompRuleBalanced
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* 转义字符
|
||||
*/
|
||||
private const val ESC = '\\'
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
import io.legato.kazusa.utils.GSON
|
||||
|
||||
class RuleData : RuleDataInterface {
|
||||
|
||||
override val variableMap by lazy {
|
||||
hashMapOf<String, String>()
|
||||
}
|
||||
|
||||
override fun putBigVariable(key: String, value: String?) {
|
||||
if (value == null) {
|
||||
variableMap.remove(key)
|
||||
} else {
|
||||
variableMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBigVariable(key: String): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun getVariable(): String? {
|
||||
if (variableMap.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return GSON.toJson(variableMap)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.legato.kazusa.model.analyzeRule
|
||||
|
||||
interface RuleDataInterface {
|
||||
|
||||
val variableMap: HashMap<String, String>
|
||||
|
||||
fun putVariable(key: String, value: String?): Boolean {
|
||||
val keyExist = variableMap.contains(key)
|
||||
return when {
|
||||
value == null -> {
|
||||
variableMap.remove(key)
|
||||
putBigVariable(key, null)
|
||||
keyExist
|
||||
}
|
||||
|
||||
value.length < 10000 -> {
|
||||
putBigVariable(key, null)
|
||||
variableMap[key] = value
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
variableMap.remove(key)
|
||||
putBigVariable(key, value)
|
||||
keyExist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun putBigVariable(key: String, value: String?)
|
||||
|
||||
fun getVariable(key: String): String {
|
||||
return variableMap[key] ?: getBigVariable(key) ?: ""
|
||||
}
|
||||
|
||||
fun getBigVariable(key: String): String?
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
*companion object interface
|
||||
*see EpubFile.kt
|
||||
*/
|
||||
interface BaseLocalBookParse {
|
||||
|
||||
fun upBookInfo(book: Book)
|
||||
|
||||
fun getChapterList(book: Book): ArrayList<BookChapter>
|
||||
|
||||
fun getContent(book: Book, chapter: BookChapter): String?
|
||||
|
||||
fun getImage(book: Book, href: String): InputStream?
|
||||
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.TextUtils
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.encodeURI
|
||||
import io.legato.kazusa.utils.isXml
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import me.ag2s.epublib.domain.EpubBook
|
||||
import me.ag2s.epublib.domain.Resource
|
||||
import me.ag2s.epublib.domain.TOCReference
|
||||
import me.ag2s.epublib.epub.EpubReader
|
||||
import me.ag2s.epublib.util.zip.AndroidZipFile
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.jsoup.select.Elements
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class EpubFile(var book: Book) {
|
||||
|
||||
companion object : BaseLocalBookParse {
|
||||
private var eFile: EpubFile? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getEFile(book: Book): EpubFile {
|
||||
if (eFile == null || eFile?.book?.bookUrl != book.bookUrl) {
|
||||
eFile = EpubFile(book)
|
||||
//对于Epub文件默认不启用替换
|
||||
//io.legado.app.data.entities.Book getUseReplaceRule
|
||||
return eFile!!
|
||||
}
|
||||
eFile?.book = book
|
||||
return eFile!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
return getEFile(book).getChapterList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getContent(book: Book, chapter: BookChapter): String? {
|
||||
return getEFile(book).getContent(chapter)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getImage(
|
||||
book: Book,
|
||||
href: String
|
||||
): InputStream? {
|
||||
return getEFile(book).getImage(href)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun upBookInfo(book: Book) {
|
||||
return getEFile(book).upBookInfo()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
eFile = null
|
||||
}
|
||||
}
|
||||
|
||||
private var mCharset: Charset = Charset.defaultCharset()
|
||||
|
||||
/**
|
||||
*持有引用,避免被回收
|
||||
*/
|
||||
private var fileDescriptor: ParcelFileDescriptor? = null
|
||||
private var epubBook: EpubBook? = null
|
||||
get() {
|
||||
if (field == null || fileDescriptor == null) {
|
||||
field = readEpub()
|
||||
}
|
||||
return field
|
||||
}
|
||||
private var epubBookContents: List<Resource>? = null
|
||||
get() {
|
||||
if (field == null || fileDescriptor == null) {
|
||||
field = epubBook?.contents
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
init {
|
||||
upBookCover(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写epub文件解析代码,直接读出压缩包文件生成Resources给epublib,这样的好处是可以逐一修改某些文件的格式错误
|
||||
*/
|
||||
private fun readEpub(): EpubBook? {
|
||||
return kotlin.runCatching {
|
||||
//ContentScheme拷贝到私有文件夹采用懒加载防止OOM
|
||||
//val zipFile = BookHelp.getEpubFile(book)
|
||||
BookHelp.getBookPFD(book)?.let {
|
||||
fileDescriptor = it
|
||||
val zipFile = AndroidZipFile(it, book.originName)
|
||||
EpubReader().readEpubLazy(zipFile, "utf-8")
|
||||
}
|
||||
|
||||
|
||||
}.onFailure {
|
||||
AppLog.put("读取Epub文件失败\n${it.localizedMessage}", it)
|
||||
it.printOnDebug()
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private fun getContent(chapter: BookChapter): String? {
|
||||
/*获取当前章节文本*/
|
||||
val contents = epubBookContents ?: return null
|
||||
val nextChapterFirstResourceHref = chapter.getVariable("nextUrl").substringBeforeLast("#")
|
||||
val currentChapterFirstResourceHref = chapter.url.substringBeforeLast("#")
|
||||
val isLastChapter = nextChapterFirstResourceHref.isBlank()
|
||||
val startFragmentId = chapter.startFragmentId
|
||||
val endFragmentId = chapter.endFragmentId
|
||||
val elements = Elements()
|
||||
var findChapterFirstSource = false
|
||||
val includeNextChapterResource = !endFragmentId.isNullOrBlank()
|
||||
/*一些书籍依靠href索引的resource会包含多个章节,需要依靠fragmentId来截取到当前章节的内容*/
|
||||
/*注:这里较大增加了内容加载的时间,所以首次获取内容后可存储到本地cache,减少重复加载*/
|
||||
for (res in contents) {
|
||||
if (!findChapterFirstSource) {
|
||||
if (currentChapterFirstResourceHref != res.href) continue
|
||||
findChapterFirstSource = true
|
||||
// 第一个xhtml文件
|
||||
elements.add(
|
||||
getBody(res, startFragmentId, endFragmentId)
|
||||
)
|
||||
// 不是最后章节 且 已经遍历到下一章节的内容时停止
|
||||
if (!isLastChapter && res.href == nextChapterFirstResourceHref) break
|
||||
continue
|
||||
}
|
||||
if (nextChapterFirstResourceHref != res.href) {
|
||||
// 其余部分
|
||||
elements.add(getBody(res, null, null))
|
||||
} else {
|
||||
// 下一章节的第一个xhtml
|
||||
if (includeNextChapterResource) {
|
||||
//有Fragment 则添加到上一章节
|
||||
elements.add(getBody(res, null, endFragmentId))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
//title标签中的内容不需要显示在正文中,去除
|
||||
elements.select("title").remove()
|
||||
elements.select("[style*=display:none]").remove()
|
||||
elements.select("img[src=\"cover.jpeg\"]").forEachIndexed { i, it ->
|
||||
if (i > 0) it.remove()
|
||||
}
|
||||
elements.select("img").forEach {
|
||||
if (it.attributesSize() <= 1) {
|
||||
return@forEach
|
||||
}
|
||||
val src = it.attr("src")
|
||||
it.clearAttributes()
|
||||
it.attr("src", src)
|
||||
}
|
||||
val tag = Book.rubyTag
|
||||
if (book.getDelTag(tag)) {
|
||||
elements.select("rp, rt").remove()
|
||||
}
|
||||
val html = elements.outerHtml()
|
||||
return HtmlFormatter.formatKeepImg(html)
|
||||
}
|
||||
|
||||
private fun getBody(res: Resource, startFragmentId: String?, endFragmentId: String?): Element {
|
||||
/**
|
||||
* <image width="1038" height="670" xlink:href="..."/>
|
||||
* ...titlepage.xhtml
|
||||
* 大多数epub文件的封面页都会带有cover,可以一定程度上解决封面读取问题
|
||||
*/
|
||||
if (res.href.contains("titlepage.xhtml") ||
|
||||
res.href.contains("cover")
|
||||
) {
|
||||
return Jsoup.parseBodyFragment("<img src=\"cover.jpeg\" />")
|
||||
}
|
||||
|
||||
// Jsoup可能会修复不规范的xhtml文件 解析处理后再获取
|
||||
var bodyElement = Jsoup.parse(String(res.data, mCharset)).body()
|
||||
bodyElement.children().run {
|
||||
select("script").remove()
|
||||
select("style").remove()
|
||||
}
|
||||
// 获取body对应的文本
|
||||
var bodyString = bodyElement.outerHtml()
|
||||
val originBodyString = bodyString
|
||||
/**
|
||||
* 某些xhtml文件 章节标题和内容不在一个节点或者不是兄弟节点
|
||||
* <div>
|
||||
* <a class="mulu1>目录1</a>
|
||||
* </div>
|
||||
* <p>....</p>
|
||||
* <div>
|
||||
* <a class="mulu2>目录2</a>
|
||||
* </div>
|
||||
* <p>....</p>
|
||||
* 先找到FragmentId对应的Element 然后直接截取之间的html
|
||||
*/
|
||||
if (!startFragmentId.isNullOrBlank()) {
|
||||
bodyElement.getElementById(startFragmentId)?.outerHtml()?.let {
|
||||
val tagStart = it.substringBefore("\n")
|
||||
bodyString = tagStart + bodyString.substringAfter(tagStart)
|
||||
}
|
||||
}
|
||||
if (!endFragmentId.isNullOrBlank() && endFragmentId != startFragmentId) {
|
||||
bodyElement.getElementById(endFragmentId)?.outerHtml()?.let {
|
||||
val tagStart = it.substringBefore("\n")
|
||||
bodyString = bodyString.substringBefore(tagStart)
|
||||
}
|
||||
}
|
||||
//截取过再重新解析
|
||||
if (bodyString != originBodyString) {
|
||||
bodyElement = Jsoup.parse(bodyString).body()
|
||||
}
|
||||
/*选择去除正文中的H标签,部分书籍标题与阅读标题重复待优化*/
|
||||
val tag = Book.hTag
|
||||
if (book.getDelTag(tag)) {
|
||||
bodyElement.run {
|
||||
select("h1, h2, h3, h4, h5, h6").remove()
|
||||
//getElementsMatchingOwnText(chapter.title)?.remove()
|
||||
}
|
||||
}
|
||||
bodyElement.select("image").forEach {
|
||||
it.tagName("img", Parser.NamespaceHtml)
|
||||
it.attr("src", it.attr("xlink:href"))
|
||||
}
|
||||
bodyElement.select("img").forEach {
|
||||
val src = it.attr("src").trim().encodeURI()
|
||||
val href = res.href.encodeURI()
|
||||
val resolvedHref = URLDecoder.decode(URI(href).resolve(src).toString(), "UTF-8")
|
||||
it.attr("src", resolvedHref)
|
||||
}
|
||||
return bodyElement
|
||||
}
|
||||
|
||||
private fun getImage(href: String): InputStream? {
|
||||
if (href == "cover.jpeg") return epubBook?.coverImage?.inputStream
|
||||
val abHref = URLDecoder.decode(href, "UTF-8")
|
||||
return epubBook?.resources?.getByHref(abHref)?.inputStream
|
||||
}
|
||||
|
||||
private fun upBookCover(fastCheck: Boolean = false) {
|
||||
try {
|
||||
epubBook?.let {
|
||||
if (book.coverUrl.isNullOrEmpty()) {
|
||||
book.coverUrl = LocalBook.getCoverPath(book)
|
||||
}
|
||||
if (fastCheck && File(book.coverUrl!!).exists()) {
|
||||
return
|
||||
}
|
||||
/*部分书籍DRM处理后,封面获取异常,待优化*/
|
||||
it.coverImage?.inputStream?.use { input ->
|
||||
val cover = BitmapFactory.decodeStream(input)
|
||||
val out = FileOutputStream(FileUtils.createFileIfNotExist(book.coverUrl!!))
|
||||
cover.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
} ?: AppLog.putDebug("Epub: 封面获取为空. path: ${book.bookUrl}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e)
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookInfo() {
|
||||
if (epubBook == null) {
|
||||
eFile = null
|
||||
book.intro = "书籍导入异常"
|
||||
} else {
|
||||
upBookCover()
|
||||
val metadata = epubBook!!.metadata
|
||||
book.name = metadata.firstTitle
|
||||
if (book.name.isEmpty()) {
|
||||
book.name = book.originName.replace(".epub", "")
|
||||
}
|
||||
|
||||
if (metadata.authors.isNotEmpty()) {
|
||||
val author =
|
||||
metadata.authors[0].toString().replace("^, |, $".toRegex(), "")
|
||||
book.author = author
|
||||
}
|
||||
if (metadata.descriptions.isNotEmpty()) {
|
||||
val desc = metadata.descriptions[0]
|
||||
book.intro = if (desc.isXml()) {
|
||||
Jsoup.parse(metadata.descriptions[0]).text()
|
||||
} else {
|
||||
desc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChapterList(): ArrayList<BookChapter> {
|
||||
val chapterList = ArrayList<BookChapter>()
|
||||
epubBook?.let { eBook ->
|
||||
val refs = eBook.tableOfContents.tocReferences
|
||||
if (refs == null || refs.isEmpty()) {
|
||||
AppLog.putDebug("Epub: NCX file parse error, check the file: ${book.bookUrl}")
|
||||
val spineReferences = eBook.spine.spineReferences
|
||||
var i = 0
|
||||
val size = spineReferences.size
|
||||
while (i < size) {
|
||||
val resource = spineReferences[i].resource
|
||||
var title = resource.title
|
||||
if (TextUtils.isEmpty(title)) {
|
||||
try {
|
||||
val doc =
|
||||
Jsoup.parse(String(resource.data, mCharset))
|
||||
val elements = doc.getElementsByTag("title")
|
||||
if (elements.isNotEmpty()) {
|
||||
title = elements[0].text()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
val chapter = BookChapter()
|
||||
chapter.index = i
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.url = resource.href
|
||||
if (i == 0 && title.isEmpty()) {
|
||||
chapter.title = "封面"
|
||||
} else {
|
||||
chapter.title = title
|
||||
}
|
||||
chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url)
|
||||
chapterList.add(chapter)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
parseFirstPage(chapterList, refs)
|
||||
parseMenu(chapterList, refs, 0)
|
||||
for (i in chapterList.indices) {
|
||||
chapterList[i].index = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapterList
|
||||
}
|
||||
|
||||
/*获取书籍起始页内容。部分书籍第一章之前存在封面,引言,扉页等内容*/
|
||||
/*tile获取不同书籍风格杂乱,格式化处理待优化*/
|
||||
private var durIndex = 0
|
||||
private fun parseFirstPage(
|
||||
chapterList: ArrayList<BookChapter>,
|
||||
refs: List<TOCReference>?
|
||||
) {
|
||||
val contents = epubBook?.contents
|
||||
if (epubBook == null || contents == null || refs == null) return
|
||||
val firstRef = refs.firstOrNull { it.resource != null } ?: return
|
||||
var i = 0
|
||||
durIndex = 0
|
||||
while (i < contents.size) {
|
||||
val content = contents[i]
|
||||
if (!content.mediaType.toString().contains("htm")) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
/**
|
||||
* 检索到第一章href停止
|
||||
* completeHref可能有fragment(#id) 必须去除
|
||||
* fix https://github.com/gedoor/legado/issues/1932
|
||||
*/
|
||||
if (firstRef.completeHref.substringBeforeLast("#") == content.href) break
|
||||
val chapter = BookChapter()
|
||||
var title = content.title
|
||||
if (TextUtils.isEmpty(title)) {
|
||||
val elements = Jsoup.parse(
|
||||
String(epubBook!!.resources.getByHref(content.href).data, mCharset)
|
||||
).getElementsByTag("title")
|
||||
title =
|
||||
if (elements.isNotEmpty() && elements[0].text().isNotBlank())
|
||||
elements[0].text()
|
||||
else
|
||||
"--卷首--"
|
||||
}
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = title
|
||||
chapter.url = content.href
|
||||
chapter.startFragmentId =
|
||||
if (content.href.substringAfter("#") == content.href) null
|
||||
else content.href.substringAfter("#")
|
||||
|
||||
chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId
|
||||
chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url)
|
||||
chapterList.add(chapter)
|
||||
durIndex++
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMenu(
|
||||
chapterList: ArrayList<BookChapter>,
|
||||
refs: List<TOCReference>?,
|
||||
level: Int
|
||||
) {
|
||||
refs?.forEach { ref ->
|
||||
if (ref.resource != null) {
|
||||
val chapter = BookChapter()
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = ref.title
|
||||
chapter.url = ref.completeHref
|
||||
chapter.startFragmentId = ref.fragmentId
|
||||
chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId
|
||||
chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url)
|
||||
chapterList.add(chapter)
|
||||
durIndex++
|
||||
}
|
||||
if (ref.children != null && ref.children.isNotEmpty()) {
|
||||
chapterList.lastOrNull()?.isVolume = true
|
||||
parseMenu(chapterList, ref.children, level + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected fun finalize() {
|
||||
fileDescriptor?.close()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.script.ScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.exception.EmptyFileException
|
||||
import io.legato.kazusa.exception.NoBooksDirException
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.exception.TocEmptyException
|
||||
import io.legato.kazusa.help.AppWebDav
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.ContentProcessor
|
||||
import io.legato.kazusa.help.book.addType
|
||||
import io.legato.kazusa.help.book.archiveName
|
||||
import io.legato.kazusa.help.book.getArchiveUri
|
||||
import io.legato.kazusa.help.book.getLocalUri
|
||||
import io.legato.kazusa.help.book.getRemoteUrl
|
||||
import io.legato.kazusa.help.book.isArchive
|
||||
import io.legato.kazusa.help.book.isEpub
|
||||
import io.legato.kazusa.help.book.isMobi
|
||||
import io.legato.kazusa.help.book.isPdf
|
||||
import io.legato.kazusa.help.book.isUmd
|
||||
import io.legato.kazusa.help.book.removeLocalUriCache
|
||||
import io.legato.kazusa.help.book.simulatedTotalChapterNum
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.lib.webdav.WebDav
|
||||
import io.legato.kazusa.lib.webdav.WebDavException
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.ArchiveUtils
|
||||
import io.legato.kazusa.utils.FileDoc
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.externalFiles
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import io.legato.kazusa.utils.inputStream
|
||||
import io.legato.kazusa.utils.isAbsUrl
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.isDataUrl
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
import splitties.init.appCtx
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* 书籍文件导入 目录正文解析
|
||||
* 支持在线文件(txt epub umd 压缩文件 本地文件
|
||||
*/
|
||||
object LocalBook {
|
||||
|
||||
private val nameAuthorPatterns = arrayOf(
|
||||
Pattern.compile("(.*?)《([^《》]+)》.*?作者:(.*)"),
|
||||
Pattern.compile("(.*?)《([^《》]+)》(.*)"),
|
||||
Pattern.compile("(^)(.+) 作者:(.+)$"),
|
||||
Pattern.compile("(^)(.+) by (.+)$")
|
||||
)
|
||||
|
||||
@Throws(FileNotFoundException::class, SecurityException::class)
|
||||
fun getBookInputStream(book: Book): InputStream {
|
||||
val uri = book.getLocalUri()
|
||||
val inputStream = uri.inputStream(appCtx).getOrNull()
|
||||
?: let {
|
||||
book.removeLocalUriCache()
|
||||
val localArchiveUri = book.getArchiveUri()
|
||||
val webDavUrl = book.getRemoteUrl()
|
||||
if (localArchiveUri != null) {
|
||||
// 重新导入对应的压缩包
|
||||
importArchiveFile(localArchiveUri, book.originName) {
|
||||
it.contains(book.originName)
|
||||
}.firstOrNull()?.let {
|
||||
getBookInputStream(it)
|
||||
}
|
||||
} else if (webDavUrl != null && downloadRemoteBook(book)) {
|
||||
// 下载远程链接
|
||||
getBookInputStream(book)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (inputStream != null) return inputStream
|
||||
book.removeLocalUriCache()
|
||||
throw FileNotFoundException("${uri.path} 文件不存在")
|
||||
}
|
||||
|
||||
fun getLastModified(book: Book): Result<Long> {
|
||||
return kotlin.runCatching {
|
||||
val uri = Uri.parse(book.bookUrl)
|
||||
if (uri.isContentScheme()) {
|
||||
return@runCatching DocumentFile.fromSingleUri(appCtx, uri)!!.lastModified()
|
||||
}
|
||||
val file = File(uri.path!!)
|
||||
if (file.exists()) {
|
||||
return@runCatching file.lastModified()
|
||||
}
|
||||
throw FileNotFoundException("${uri.path} 文件不存在")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(TocEmptyException::class)
|
||||
fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
val chapters = when {
|
||||
book.isEpub -> {
|
||||
EpubFile.getChapterList(book)
|
||||
}
|
||||
|
||||
book.isUmd -> {
|
||||
UmdFile.getChapterList(book)
|
||||
}
|
||||
|
||||
book.isPdf -> {
|
||||
PdfFile.getChapterList(book)
|
||||
}
|
||||
|
||||
book.isMobi -> {
|
||||
MobiFile.getChapterList(book)
|
||||
}
|
||||
|
||||
else -> {
|
||||
TextFile.getChapterList(book)
|
||||
}
|
||||
}
|
||||
if (chapters.isEmpty()) {
|
||||
throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty))
|
||||
}
|
||||
val list = ArrayList(LinkedHashSet(chapters))
|
||||
list.forEachIndexed { index, bookChapter ->
|
||||
bookChapter.index = index
|
||||
}
|
||||
val replaceRules = ContentProcessor.get(book).getTitleReplaceRules()
|
||||
book.durChapterTitle = list.getOrElse(book.durChapterIndex) { list.last() }
|
||||
.getDisplayTitle(replaceRules, book.getUseReplaceRule())
|
||||
book.latestChapterTitle =
|
||||
list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
|
||||
.getDisplayTitle(replaceRules, book.getUseReplaceRule())
|
||||
book.totalChapterNum = list.size
|
||||
book.latestChapterTime = System.currentTimeMillis()
|
||||
return list
|
||||
}
|
||||
|
||||
fun getContent(book: Book, chapter: BookChapter): String? {
|
||||
var content = try {
|
||||
when {
|
||||
book.isEpub -> {
|
||||
EpubFile.getContent(book, chapter)
|
||||
}
|
||||
|
||||
book.isUmd -> {
|
||||
UmdFile.getContent(book, chapter)
|
||||
}
|
||||
|
||||
book.isPdf -> {
|
||||
PdfFile.getContent(book, chapter)
|
||||
}
|
||||
|
||||
book.isMobi -> {
|
||||
MobiFile.getContent(book, chapter)
|
||||
}
|
||||
|
||||
else -> {
|
||||
TextFile.getContent(book, chapter)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
AppLog.put("获取本地书籍内容失败\n${e.localizedMessage}", e)
|
||||
"获取本地书籍内容失败\n${e.localizedMessage}"
|
||||
}
|
||||
if (book.isEpub) {
|
||||
content ?: return null
|
||||
if (content.indexOf('&') > -1) {
|
||||
content = content.replace("<img", "< img", true)
|
||||
return StringEscapeUtils.unescapeHtml4(content)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
fun getCoverPath(book: Book): String {
|
||||
return getCoverPath(book.bookUrl)
|
||||
}
|
||||
|
||||
private fun getCoverPath(bookUrl: String): String {
|
||||
return FileUtils.getPath(
|
||||
appCtx.externalFiles,
|
||||
"covers",
|
||||
"${MD5Utils.md5Encode16(bookUrl)}.jpg"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载在线的文件并自动导入到阅读(txt umd epub)
|
||||
*/
|
||||
suspend fun importFileOnLine(
|
||||
str: String,
|
||||
fileName: String,
|
||||
source: BaseSource? = null,
|
||||
): Book {
|
||||
return importFile(saveBookFile(str, fileName, source))
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入本地文件
|
||||
*/
|
||||
fun importFile(uri: Uri): Book {
|
||||
val bookUrl: String
|
||||
//updateTime变量不要修改,否则会导致读取不到缓存
|
||||
val (fileName, _, _, updateTime, _) = FileDoc.fromUri(uri, false).apply {
|
||||
if (size == 0L) throw EmptyFileException("Unexpected empty File")
|
||||
|
||||
bookUrl = toString()
|
||||
}
|
||||
var book = appDb.bookDao.getBook(bookUrl)
|
||||
if (book == null) {
|
||||
val nameAuthor = analyzeNameAuthor(fileName)
|
||||
book = Book(
|
||||
type = BookType.text or BookType.local,
|
||||
bookUrl = bookUrl,
|
||||
name = nameAuthor.first,
|
||||
author = nameAuthor.second,
|
||||
originName = fileName,
|
||||
latestChapterTime = updateTime,
|
||||
order = appDb.bookDao.minOrder - 1
|
||||
)
|
||||
upBookInfo(book)
|
||||
appDb.bookDao.insert(book)
|
||||
} else {
|
||||
deleteBook(book, false)
|
||||
upBookInfo(book)
|
||||
// 触发 isLocalModified
|
||||
book.latestChapterTime = 0
|
||||
//已有书籍说明是更新,删除原有目录
|
||||
appDb.bookChapterDao.delByBook(bookUrl)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
fun upBookInfo(book: Book) {
|
||||
when {
|
||||
book.isEpub -> EpubFile.upBookInfo(book)
|
||||
book.isUmd -> UmdFile.upBookInfo(book)
|
||||
book.isPdf -> PdfFile.upBookInfo(book)
|
||||
book.isMobi -> MobiFile.upBookInfo(book)
|
||||
}
|
||||
}
|
||||
|
||||
/* 导入压缩包内的书籍 */
|
||||
fun importArchiveFile(
|
||||
archiveFileUri: Uri,
|
||||
saveFileName: String? = null,
|
||||
filter: ((String) -> Boolean)? = null
|
||||
): List<Book> {
|
||||
val archiveFileDoc = FileDoc.fromUri(archiveFileUri, false)
|
||||
val files = ArchiveUtils.deCompress(archiveFileDoc, filter = filter)
|
||||
if (files.isEmpty()) {
|
||||
throw NoStackTraceException(appCtx.getString(R.string.unsupport_archivefile_entry))
|
||||
}
|
||||
return files.map {
|
||||
saveBookFile(FileInputStream(it), saveFileName ?: it.name).let { uri ->
|
||||
importFile(uri).apply {
|
||||
//附加压缩包名称 以便解压文件被删后再解压
|
||||
origin = "${BookType.localTag}::${archiveFileDoc.name}"
|
||||
addType(BookType.archive)
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量导入 支持自动导入压缩包的支持书籍 */
|
||||
fun importFiles(uri: Uri): List<Book> {
|
||||
val books = mutableListOf<Book>()
|
||||
val fileDoc = FileDoc.fromUri(uri, false)
|
||||
if (ArchiveUtils.isArchive(fileDoc.name)) {
|
||||
books.addAll(
|
||||
importArchiveFile(uri) {
|
||||
it.matches(AppPattern.bookFileRegex)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
books.add(importFile(uri))
|
||||
}
|
||||
return books
|
||||
}
|
||||
|
||||
fun importFiles(uris: List<Uri>) {
|
||||
var errorCount = 0
|
||||
uris.forEach { uri ->
|
||||
val fileDoc = FileDoc.fromUri(uri, false)
|
||||
kotlin.runCatching {
|
||||
if (ArchiveUtils.isArchive(fileDoc.name)) {
|
||||
importArchiveFile(uri) {
|
||||
it.matches(AppPattern.bookFileRegex)
|
||||
}
|
||||
} else {
|
||||
importFile(uri)
|
||||
}
|
||||
}.onFailure {
|
||||
AppLog.put("ImportFile Error:\nFile $fileDoc\n${it.localizedMessage}", it)
|
||||
errorCount += 1
|
||||
}
|
||||
}
|
||||
if (errorCount == uris.size) {
|
||||
throw NoStackTraceException("ImportFiles Error:\nAll input files occur error")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件分析书籍必要信息(书名 作者等)
|
||||
*/
|
||||
private fun analyzeNameAuthor(fileName: String): Pair<String, String> {
|
||||
val tempFileName = fileName.substringBeforeLast(".")
|
||||
var name = ""
|
||||
var author = ""
|
||||
if (!AppConfig.bookImportFileName.isNullOrBlank()) {
|
||||
try {
|
||||
//在用户脚本后添加捕获author、name的代码,只要脚本中author、name有值就会被捕获
|
||||
val js =
|
||||
AppConfig.bookImportFileName + "\nJSON.stringify({author:author,name:name})"
|
||||
//在脚本中定义如何分解文件名成书名、作者名
|
||||
val jsonStr = RhinoScriptEngine.run {
|
||||
val bindings = ScriptBindings()
|
||||
bindings["src"] = tempFileName
|
||||
eval(js, bindings)
|
||||
}.toString()
|
||||
val bookMess = GSON.fromJsonObject<HashMap<String, String>>(jsonStr)
|
||||
.getOrThrow()
|
||||
name = bookMess["name"] ?: ""
|
||||
author = bookMess["author"]?.takeIf { it.length != tempFileName.length } ?: ""
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("执行导入文件名规则出错\n${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
if (name.isBlank()) {
|
||||
for (pattern in nameAuthorPatterns) {
|
||||
pattern.matcher(tempFileName).takeIf { it.find() }?.run {
|
||||
name = group(2)!!
|
||||
val group1 = group(1) ?: ""
|
||||
val group3 = group(3) ?: ""
|
||||
author = BookHelp.formatBookAuthor(group1 + group3)
|
||||
return Pair(name, author)
|
||||
}
|
||||
}
|
||||
name = BookHelp.formatBookName(tempFileName)
|
||||
author = BookHelp.formatBookAuthor(tempFileName.replace(name, ""))
|
||||
.takeIf { it.length != tempFileName.length } ?: ""
|
||||
}
|
||||
return Pair(name, author)
|
||||
}
|
||||
|
||||
fun deleteBook(book: Book, deleteOriginal: Boolean) {
|
||||
kotlin.runCatching {
|
||||
BookHelp.clearCache(book)
|
||||
if (!book.coverUrl.isNullOrEmpty()) {
|
||||
FileUtils.delete(book.coverUrl!!)
|
||||
}
|
||||
if (deleteOriginal) {
|
||||
if (book.bookUrl.isContentScheme()) {
|
||||
val uri = Uri.parse(book.bookUrl)
|
||||
DocumentFile.fromSingleUri(appCtx, uri)?.delete()
|
||||
} else {
|
||||
FileUtils.delete(book.bookUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载在线的文件
|
||||
*/
|
||||
suspend fun saveBookFile(
|
||||
str: String,
|
||||
fileName: String,
|
||||
source: BaseSource? = null,
|
||||
): Uri {
|
||||
AppConfig.defaultBookTreeUri
|
||||
?: throw NoBooksDirException()
|
||||
val inputStream = when {
|
||||
str.isAbsUrl() -> AnalyzeUrl(
|
||||
str, source = source, callTimeout = 0,
|
||||
coroutineContext = coroutineContext
|
||||
).getInputStreamAwait()
|
||||
|
||||
str.isDataUrl() -> ByteArrayInputStream(
|
||||
Base64.decode(
|
||||
str.substringAfter("base64,"),
|
||||
Base64.DEFAULT
|
||||
)
|
||||
)
|
||||
|
||||
else -> throw NoStackTraceException("在线导入书籍支持http/https/DataURL")
|
||||
}
|
||||
return saveBookFile(inputStream, fileName)
|
||||
}
|
||||
|
||||
@Throws(SecurityException::class)
|
||||
fun saveBookFile(
|
||||
inputStream: InputStream,
|
||||
fileName: String
|
||||
): Uri {
|
||||
inputStream.use {
|
||||
val defaultBookTreeUri = AppConfig.defaultBookTreeUri
|
||||
if (defaultBookTreeUri.isNullOrBlank()) throw NoBooksDirException()
|
||||
val treeUri = Uri.parse(defaultBookTreeUri)
|
||||
return if (treeUri.isContentScheme()) {
|
||||
val treeDoc = DocumentFile.fromTreeUri(appCtx, treeUri)
|
||||
var doc = treeDoc!!.findFile(fileName)
|
||||
if (doc == null) {
|
||||
doc = treeDoc.createFile(FileUtils.getMimeType(fileName), fileName)
|
||||
?: throw SecurityException("请重新设置书籍保存位置\nPermission Denial")
|
||||
}
|
||||
appCtx.contentResolver.openOutputStream(doc.uri)!!.use { oStream ->
|
||||
it.copyTo(oStream)
|
||||
}
|
||||
doc.uri
|
||||
} else {
|
||||
try {
|
||||
val treeFile = File(treeUri.path!!)
|
||||
val file = treeFile.getFile(fileName)
|
||||
FileOutputStream(file).use { oStream ->
|
||||
it.copyTo(oStream)
|
||||
}
|
||||
Uri.fromFile(file)
|
||||
} catch (e: FileNotFoundException) {
|
||||
throw SecurityException("请重新设置书籍保存位置\nPermission Denial\n$e").apply {
|
||||
addSuppressed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isOnBookShelf(
|
||||
fileName: String
|
||||
): Boolean {
|
||||
return appDb.bookDao.hasFile(fileName) == true
|
||||
}
|
||||
|
||||
//文件类书源 合并在线书籍信息 在线 > 本地
|
||||
fun mergeBook(localBook: Book, onLineBook: Book?): Book {
|
||||
onLineBook ?: return localBook
|
||||
localBook.name = onLineBook.name.ifBlank { localBook.name }
|
||||
localBook.author = onLineBook.author.ifBlank { localBook.author }
|
||||
localBook.coverUrl = onLineBook.coverUrl
|
||||
localBook.intro =
|
||||
if (onLineBook.intro.isNullOrBlank()) localBook.intro else onLineBook.intro
|
||||
localBook.save()
|
||||
return localBook
|
||||
}
|
||||
|
||||
//下载book对应的远程文件 并更新Book
|
||||
private fun downloadRemoteBook(localBook: Book): Boolean {
|
||||
val webDavUrl = localBook.getRemoteUrl()
|
||||
if (webDavUrl.isNullOrBlank()) throw NoStackTraceException("Book file is not webDav File")
|
||||
try {
|
||||
AppConfig.defaultBookTreeUri
|
||||
?: throw NoBooksDirException()
|
||||
// 兼容旧版链接
|
||||
val webdav: WebDav = kotlin.runCatching {
|
||||
WebDav.fromPath(webDavUrl)
|
||||
}.getOrElse {
|
||||
AppWebDav.authorization?.let { WebDav(webDavUrl, it) }
|
||||
?: throw WebDavException("Unexpected defaultBookWebDav")
|
||||
}
|
||||
val inputStream = runBlocking {
|
||||
webdav.downloadInputStream()
|
||||
}
|
||||
inputStream.use {
|
||||
if (localBook.isArchive) {
|
||||
// 压缩包
|
||||
val archiveUri = saveBookFile(it, localBook.archiveName)
|
||||
val newBook = importArchiveFile(archiveUri, localBook.originName) { name ->
|
||||
name.contains(localBook.originName)
|
||||
}.first()
|
||||
localBook.origin = newBook.origin
|
||||
localBook.bookUrl = newBook.bookUrl
|
||||
} else {
|
||||
// txt epub pdf umd
|
||||
val fileUri = saveBookFile(it, localBook.originName)
|
||||
localBook.bookUrl = FileDoc.fromUri(fileUri, false).toString()
|
||||
localBook.save()
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
AppLog.put("自动下载webDav书籍失败", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.ParcelFileDescriptor
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.lib.mobi.KF6Book
|
||||
import io.legato.kazusa.lib.mobi.KF8Book
|
||||
import io.legato.kazusa.lib.mobi.MobiBook
|
||||
import io.legato.kazusa.lib.mobi.MobiReader
|
||||
import io.legato.kazusa.lib.mobi.entities.TOC
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import org.jsoup.Jsoup
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class MobiFile(var book: Book) {
|
||||
|
||||
companion object : BaseLocalBookParse {
|
||||
private var mFile: MobiFile? = null
|
||||
private val xmlDeclarationRegex = "<\\?xml[^>]*>".toRegex()
|
||||
private val doctypeDeclarationRegex = "<!DOCTYPE[^>]*>".toRegex()
|
||||
|
||||
@Synchronized
|
||||
private fun getMFile(book: Book): MobiFile {
|
||||
if (mFile == null || mFile?.book?.bookUrl != book.bookUrl) {
|
||||
mFile = MobiFile(book)
|
||||
return mFile!!
|
||||
}
|
||||
mFile?.book = book
|
||||
return mFile!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
return getMFile(book).getChapterList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getContent(book: Book, chapter: BookChapter): String? {
|
||||
return getMFile(book).getContent(chapter)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getImage(book: Book, href: String): InputStream? {
|
||||
return getMFile(book).getImage(href)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun upBookInfo(book: Book) {
|
||||
return getMFile(book).upBookInfo()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
mFile = null
|
||||
}
|
||||
}
|
||||
|
||||
private var fileDescriptor: ParcelFileDescriptor? = null
|
||||
private var mobiBook: MobiBook? = null
|
||||
get() {
|
||||
if (field == null || fileDescriptor == null) {
|
||||
field = readMobi()
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
init {
|
||||
upBookCover(true)
|
||||
}
|
||||
|
||||
private fun readMobi(): MobiBook? {
|
||||
return kotlin.runCatching {
|
||||
BookHelp.getBookPFD(book)?.let {
|
||||
fileDescriptor = it
|
||||
MobiReader().readMobi(it)
|
||||
}
|
||||
}.onFailure {
|
||||
AppLog.put("读取Mobi文件失败\n${it.localizedMessage}", it)
|
||||
it.printOnDebug()
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private fun getChapterList(): ArrayList<BookChapter> {
|
||||
return when (val book = mobiBook) {
|
||||
is KF8Book -> getChapterListKF8(book)
|
||||
is KF6Book -> getChapterListKF6(book)
|
||||
else -> error("impossible condition")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChapterListKF6(kF6Book: KF6Book): ArrayList<BookChapter> {
|
||||
val chapterList = arrayListOf<BookChapter>()
|
||||
val toc = kF6Book.toc
|
||||
|
||||
if (kF6Book.sectionIdMap[0] == null) {
|
||||
val section = kF6Book.sections.firstOrNull()
|
||||
if (section != null) {
|
||||
val chapter = BookChapter()
|
||||
val content = kF6Book.getSectionText(section)
|
||||
val soup = Jsoup.parse(content)
|
||||
val title = soup.getElementsByTag("title").first()?.text() ?: "卷首"
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = title
|
||||
chapter.url = "0:" + section.href
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
fun append(ref: TOC) {
|
||||
val chapter = BookChapter()
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = ref.label
|
||||
chapter.url = "${chapterList.size}:${ref.href}"
|
||||
chapter.isVolume = ref.subitems != null
|
||||
val lastChapter = chapterList.lastOrNull()
|
||||
if (lastChapter != null &&
|
||||
lastChapter.isVolume &&
|
||||
lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":")
|
||||
) {
|
||||
lastChapter.url = "skip:" + lastChapter.url
|
||||
}
|
||||
lastChapter?.putVariable("nextUrl", chapter.url)
|
||||
chapterList.add(chapter)
|
||||
ref.subitems?.forEach(::append)
|
||||
}
|
||||
|
||||
toc?.forEach(::append)
|
||||
|
||||
return chapterList
|
||||
}
|
||||
|
||||
private fun getChapterListKF8(kf8Book: KF8Book): ArrayList<BookChapter> {
|
||||
val chapterList = arrayListOf<BookChapter>()
|
||||
val toc = kf8Book.toc
|
||||
|
||||
if (kf8Book.sectionIdMap[0] == null) {
|
||||
val section = kf8Book.sections.firstOrNull { it.href.isNotEmpty() }
|
||||
if (section != null) {
|
||||
val chapter = BookChapter()
|
||||
val content = kf8Book.getSectionText(section)
|
||||
val soup = Jsoup.parse(content)
|
||||
val title = soup.getElementsByTag("title").first()?.text() ?: "卷首"
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = title
|
||||
chapter.url = "0:" + section.href
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
fun append(ref: TOC) {
|
||||
val chapter = BookChapter()
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = ref.label
|
||||
chapter.url = "${chapterList.size}:${ref.href}"
|
||||
chapter.isVolume = ref.subitems != null
|
||||
val lastChapter = chapterList.lastOrNull()
|
||||
if (lastChapter != null &&
|
||||
lastChapter.isVolume &&
|
||||
lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":")
|
||||
) {
|
||||
lastChapter.url = "skip:" + lastChapter.url
|
||||
}
|
||||
lastChapter?.putVariable("nextUrl", chapter.url)
|
||||
chapterList.add(chapter)
|
||||
ref.subitems?.forEach(::append)
|
||||
}
|
||||
|
||||
toc?.forEach(::append)
|
||||
|
||||
return chapterList
|
||||
}
|
||||
|
||||
private fun getContent(chapter: BookChapter): String? {
|
||||
return when (val book = mobiBook) {
|
||||
is KF8Book -> getContentKF8(book, chapter)
|
||||
is KF6Book -> getContentKF6(book, chapter)
|
||||
else -> error("impossible condition")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContentKF6(kf6Book: KF6Book, chapter: BookChapter): String? {
|
||||
if (chapter.isVolume && chapter.url.startsWith("skip:")) return ""
|
||||
var section = kf6Book.getSectionByHref(chapter.url) ?: return null
|
||||
val nextSectionHref = chapter.getVariable("nextUrl")
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append(kf6Book.getSectionText(section))
|
||||
while (true) {
|
||||
section = section.next ?: break
|
||||
if (section.href == nextSectionHref) {
|
||||
break
|
||||
}
|
||||
if (kf6Book.sectionIdMap[section.index] != null) {
|
||||
break
|
||||
}
|
||||
sb.append(kf6Book.getSectionText(section))
|
||||
}
|
||||
|
||||
val soup = Jsoup.parse(sb.toString())
|
||||
|
||||
soup.select("title").remove()
|
||||
soup.select("[style*=display:none]").remove()
|
||||
soup.select("img[recindex]").forEach {
|
||||
val recindex = it.attr("recindex")
|
||||
it.clearAttributes()
|
||||
it.attr("src", "recindex:$recindex")
|
||||
}
|
||||
|
||||
return format(soup.outerHtml())
|
||||
}
|
||||
|
||||
private fun getContentKF8(kf8Book: KF8Book, chapter: BookChapter): String? {
|
||||
if (chapter.isVolume && chapter.url.startsWith("skip:")) return ""
|
||||
var section = kf8Book.getSectionByHref(chapter.url) ?: return null
|
||||
val nextSectionHref = chapter.getVariable("nextUrl")
|
||||
val nextPos = kf8Book.parsePosURI(nextSectionHref)
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append(kf8Book.getTextByHref(chapter.url, nextSectionHref))
|
||||
while (true) {
|
||||
if (nextPos != null && section.frags.any { it.index == nextPos.fid }) {
|
||||
break
|
||||
}
|
||||
section = section.next ?: break
|
||||
if (section.linear) {
|
||||
continue
|
||||
}
|
||||
if (section.href == nextSectionHref) {
|
||||
break
|
||||
}
|
||||
if (kf8Book.sectionIdMap[section.index] != null) {
|
||||
break
|
||||
}
|
||||
sb.append(kf8Book.getSectionText(section))
|
||||
}
|
||||
|
||||
val soup = Jsoup.parse(sb.toString())
|
||||
|
||||
soup.select("title").remove()
|
||||
soup.select("[style*=display:none]").remove()
|
||||
|
||||
return format(soup.outerHtml())
|
||||
}
|
||||
|
||||
private fun format(html: String): String {
|
||||
return HtmlFormatter.formatKeepImg(html)
|
||||
.replace(xmlDeclarationRegex, "")
|
||||
.replace(doctypeDeclarationRegex, "")
|
||||
}
|
||||
|
||||
private fun getImage(href: String): InputStream? {
|
||||
return when (val book = mobiBook) {
|
||||
is KF8Book -> getImageKF8(book, href)
|
||||
is KF6Book -> getImageKF6(book, href)
|
||||
else -> error("impossible condition")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageKF6(kf6Book: KF6Book, href: String): InputStream? {
|
||||
return kf6Book.getResourceByHref(href)?.inputStream()
|
||||
}
|
||||
|
||||
private fun getImageKF8(kf8Book: KF8Book, href: String): InputStream? {
|
||||
return kf8Book.getResourceByHref(href)?.inputStream()
|
||||
}
|
||||
|
||||
private fun upBookCover(fastCheck: Boolean = false) {
|
||||
try {
|
||||
mobiBook?.let {
|
||||
if (book.coverUrl.isNullOrEmpty()) {
|
||||
book.coverUrl = LocalBook.getCoverPath(book)
|
||||
}
|
||||
if (fastCheck && File(book.coverUrl!!).exists()) {
|
||||
return
|
||||
}
|
||||
it.getCover()?.let { bytes ->
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
val file = FileUtils.createFileIfNotExist(book.coverUrl!!)
|
||||
FileOutputStream(file).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e)
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookInfo() {
|
||||
if (mobiBook == null) {
|
||||
mFile = null
|
||||
book.intro = "书籍导入异常"
|
||||
} else {
|
||||
upBookCover()
|
||||
val metadata = mobiBook!!.metadata
|
||||
book.name = metadata.title
|
||||
if (book.name.isEmpty()) {
|
||||
book.name = book.originName.replace("(?i)\\.(mobi|azw3)$".toRegex(), "")
|
||||
}
|
||||
if (metadata.author.isNotEmpty()) {
|
||||
book.author = metadata.author.first()
|
||||
}
|
||||
if (metadata.description.isNotBlank()) {
|
||||
book.intro = HtmlFormatter.format(metadata.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.help.book.getLocalUri
|
||||
import io.legato.kazusa.utils.BitmapUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.SystemUtils
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
class PdfFile(var book: Book) {
|
||||
companion object : BaseLocalBookParse {
|
||||
private var pFile: PdfFile? = null
|
||||
|
||||
/**
|
||||
* pdf分页尺寸
|
||||
*/
|
||||
const val PAGE_SIZE = 10
|
||||
|
||||
@Synchronized
|
||||
private fun getPFile(book: Book): PdfFile {
|
||||
if (pFile == null || pFile?.book?.bookUrl != book.bookUrl) {
|
||||
pFile = PdfFile(book)
|
||||
return pFile!!
|
||||
}
|
||||
pFile?.book = book
|
||||
return pFile!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun upBookInfo(book: Book) {
|
||||
getPFile(book).upBookInfo()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
return getPFile(book).getChapterList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getContent(book: Book, chapter: BookChapter): String? {
|
||||
return getPFile(book).getContent(chapter)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getImage(book: Book, href: String): InputStream? {
|
||||
return getPFile(book).getImage(href)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
*持有引用,避免被回收
|
||||
*/
|
||||
private var fileDescriptor: ParcelFileDescriptor? = null
|
||||
private var pdfRenderer: PdfRenderer? = null
|
||||
get() {
|
||||
if (field != null && fileDescriptor != null) {
|
||||
return field
|
||||
}
|
||||
field = readPdf()
|
||||
return field
|
||||
}
|
||||
|
||||
init {
|
||||
upBookCover(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取PDF文件
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private fun readPdf(): PdfRenderer? {
|
||||
val uri = book.getLocalUri()
|
||||
if (uri.isContentScheme()) {
|
||||
fileDescriptor = appCtx.contentResolver.openFileDescriptor(uri, "r")?.also {
|
||||
pdfRenderer = PdfRenderer(it)
|
||||
}
|
||||
} else {
|
||||
fileDescriptor =
|
||||
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
?.also {
|
||||
pdfRenderer = PdfRenderer(it)
|
||||
}
|
||||
}
|
||||
return pdfRenderer
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭pdf文件
|
||||
*
|
||||
*/
|
||||
private fun closePdf() {
|
||||
pdfRenderer?.close()
|
||||
fileDescriptor?.close()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 渲染PDF页面
|
||||
* 根据index打开pdf页面,并渲染到Bitmap
|
||||
*
|
||||
* @param renderer
|
||||
* @param index
|
||||
* @return
|
||||
*/
|
||||
private fun openPdfPage(renderer: PdfRenderer, index: Int): Bitmap? {
|
||||
if (index >= renderer.pageCount) {
|
||||
return null
|
||||
}
|
||||
return renderer.openPage(index)?.use { page ->
|
||||
Bitmap.createBitmap(
|
||||
SystemUtils.screenWidthPx,
|
||||
(SystemUtils.screenWidthPx.toDouble() * page.height / page.width).toInt(),
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
.apply {
|
||||
this.eraseColor(Color.WHITE)
|
||||
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun getContent(chapter: BookChapter): String? =
|
||||
if (pdfRenderer == null) {
|
||||
null
|
||||
} else {
|
||||
pdfRenderer?.let { renderer ->
|
||||
|
||||
buildString {
|
||||
val start = chapter.index * PAGE_SIZE
|
||||
val end = ((chapter.index + 1) * PAGE_SIZE).coerceAtMost(renderer.pageCount)
|
||||
(start until end).forEach {
|
||||
append("<img src=").append('"').append(it).append('"').append(" >")
|
||||
.append('\n')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getImage(href: String): InputStream? {
|
||||
if (pdfRenderer == null) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val index = href.toInt()
|
||||
val bitmap = openPdfPage(pdfRenderer!!, index)
|
||||
if (bitmap != null) {
|
||||
BitmapUtils.toInputStream(bitmap)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChapterList(): ArrayList<BookChapter> {
|
||||
val chapterList = ArrayList<BookChapter>()
|
||||
|
||||
pdfRenderer?.let { renderer ->
|
||||
if (renderer.pageCount > 0) {
|
||||
val chapterCount = ceil((renderer.pageCount.toDouble() / PAGE_SIZE)).toInt()
|
||||
(0 until chapterCount).forEach {
|
||||
val chapter = BookChapter()
|
||||
chapter.index = it
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.title = "分段_${it}"
|
||||
chapter.url = "pdf_${it}"
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapterList
|
||||
}
|
||||
|
||||
private fun upBookCover(fastCheck: Boolean = false) {
|
||||
try {
|
||||
pdfRenderer?.let { renderer ->
|
||||
if (book.coverUrl.isNullOrEmpty()) {
|
||||
book.coverUrl = LocalBook.getCoverPath(book)
|
||||
}
|
||||
if (fastCheck && File(book.coverUrl!!).exists()) {
|
||||
return
|
||||
}
|
||||
FileOutputStream(FileUtils.createFileIfNotExist(book.coverUrl!!)).use { out ->
|
||||
openPdfPage(renderer, 0)?.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e)
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookInfo() {
|
||||
if (pdfRenderer == null) {
|
||||
pFile = null
|
||||
book.intro = "书籍导入异常"
|
||||
} else {
|
||||
upBookCover()
|
||||
if (book.name.isEmpty()) {
|
||||
book.name = book.originName.replace(".pdf", "")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
closePdf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# 书籍文件导入解析
|
||||
|
||||
* BaseLocalBookParse.kt 本地书籍解析接口
|
||||
* LocalBook.kt 导入解析总入口
|
||||
* TextFile.kt 解析txt
|
||||
* EpubFile.kt 解析epub
|
||||
* PdfFile.kt 解析pdf 纯图片形式
|
||||
* UmdFile.kt 解析umd
|
||||
@@ -0,0 +1,475 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.TxtTocRule
|
||||
import io.legato.kazusa.exception.EmptyFileException
|
||||
import io.legato.kazusa.help.DefaultData
|
||||
import io.legato.kazusa.help.book.isLocalModified
|
||||
import io.legato.kazusa.utils.EncodingDetect
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.StringUtils
|
||||
import io.legato.kazusa.utils.Utf8BomUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.charset.Charset
|
||||
import java.util.regex.Matcher
|
||||
import java.util.regex.Pattern
|
||||
import java.util.regex.PatternSyntaxException
|
||||
import kotlin.math.min
|
||||
|
||||
class TextFile(private var book: Book) {
|
||||
|
||||
@Suppress("ConstPropertyName")
|
||||
companion object {
|
||||
private val padRegex = "^[\\n\\s]+".toRegex()
|
||||
private const val txtBufferSize = 8 * 1024 * 1024
|
||||
private var textFile: TextFile? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getTextFile(book: Book): TextFile {
|
||||
if (textFile == null || textFile?.book?.bookUrl != book.bookUrl || book.isLocalModified()) {
|
||||
textFile = TextFile(book)
|
||||
return textFile!!
|
||||
}
|
||||
textFile?.book = book
|
||||
return textFile!!
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class)
|
||||
fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
return getTextFile(book).getChapterList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Throws(FileNotFoundException::class)
|
||||
fun getContent(book: Book, bookChapter: BookChapter): String {
|
||||
return getTextFile(book).getContent(bookChapter)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
textFile = null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val blank: Byte = 0x0a
|
||||
|
||||
//默认从文件中获取数据的长度
|
||||
private val bufferSize = 512000
|
||||
|
||||
//没有标题的时候,每个章节的最大长度
|
||||
private val maxLengthWithNoToc = 10 * 1024
|
||||
|
||||
//使用正则划分目录,每个章节的最大允许长度
|
||||
private val maxLengthWithToc = 102400
|
||||
|
||||
private var charset: Charset = book.fileCharset()
|
||||
|
||||
private var txtBuffer: ByteArray? = null
|
||||
private var bufferStart = -1L
|
||||
private var bufferEnd = -1L
|
||||
|
||||
/**
|
||||
* 获取目录
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, SecurityException::class, EmptyFileException::class)
|
||||
fun getChapterList(): ArrayList<BookChapter> {
|
||||
val modified = book.isLocalModified()
|
||||
if (book.charset == null || book.tocUrl.isBlank() || modified) {
|
||||
LocalBook.getBookInputStream(book).use { bis ->
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val length = bis.read(buffer)
|
||||
if (length == -1) throw EmptyFileException("Unexpected Empty Txt File")
|
||||
if (book.charset.isNullOrBlank() || modified) {
|
||||
book.charset = EncodingDetect.getEncode(buffer.copyOf(length))
|
||||
}
|
||||
charset = book.fileCharset()
|
||||
if (book.tocUrl.isBlank() || modified) {
|
||||
val blockContent = String(buffer, 0, length, charset)
|
||||
book.tocUrl = getTocRule(blockContent)?.pattern() ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE))
|
||||
book.wordCount = StringUtils.wordCountFormat(wordCount)
|
||||
toc.forEachIndexed { index, bookChapter ->
|
||||
bookChapter.index = index
|
||||
bookChapter.bookUrl = book.bookUrl
|
||||
bookChapter.url = MD5Utils.md5Encode16(book.originName + index + bookChapter.title)
|
||||
}
|
||||
return toc
|
||||
}
|
||||
|
||||
fun getContent(chapter: BookChapter): String {
|
||||
val start = chapter.start!!
|
||||
val end = chapter.end!!
|
||||
if (txtBuffer == null || start > bufferEnd || end < bufferStart) {
|
||||
LocalBook.getBookInputStream(book).use { bis ->
|
||||
bufferStart = txtBufferSize * (start / txtBufferSize)
|
||||
txtBuffer = ByteArray(min(txtBufferSize, bis.available() - bufferStart.toInt()))
|
||||
bufferEnd = bufferStart + txtBuffer!!.size
|
||||
bis.skip(bufferStart)
|
||||
bis.read(txtBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
val count = (end - start).toInt()
|
||||
val buffer = ByteArray(count)
|
||||
|
||||
@Suppress("ConvertTwoComparisonsToRangeCheck")
|
||||
if (start < bufferEnd && end > bufferEnd || start < bufferStart && end > bufferStart) {
|
||||
/** 章节内容在缓冲区交界处 */
|
||||
LocalBook.getBookInputStream(book).use { bis ->
|
||||
bis.skip(start)
|
||||
bis.read(buffer)
|
||||
}
|
||||
} else {
|
||||
/** 章节内容在缓冲区内 */
|
||||
txtBuffer!!.copyInto(
|
||||
buffer,
|
||||
0,
|
||||
(start - bufferStart).toInt(),
|
||||
(end - bufferStart).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
return String(buffer, charset)
|
||||
.substringAfter(chapter.title)
|
||||
.replace(padRegex, " ")
|
||||
}
|
||||
|
||||
/**
|
||||
* 按规则解析目录
|
||||
*/
|
||||
private fun analyze(pattern: Pattern?): Pair<ArrayList<BookChapter>, Int> {
|
||||
if (pattern == null || pattern.pattern().isNullOrEmpty()) {
|
||||
return analyze()
|
||||
}
|
||||
val toc = arrayListOf<BookChapter>()
|
||||
var bookWordCount = 0
|
||||
LocalBook.getBookInputStream(book).use { bis ->
|
||||
var blockContent: String
|
||||
//加载章节
|
||||
var curOffset: Long = 0
|
||||
//读取的长度
|
||||
var length: Int
|
||||
var lastChapterWordCount = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bufferStart = 3
|
||||
bis.read(buffer, 0, 3)
|
||||
if (Utf8BomUtils.hasBom(buffer)) {
|
||||
bufferStart = 0
|
||||
curOffset = 3
|
||||
}
|
||||
//获取文件中的数据到buffer,直到没有数据为止
|
||||
while (bis.read(
|
||||
buffer, bufferStart, bufferSize - bufferStart
|
||||
).also { length = it } > 0
|
||||
) {
|
||||
var end = bufferStart + length
|
||||
if (end == bufferSize) {
|
||||
for (i in bufferStart + length - 1 downTo 0) {
|
||||
if (buffer[i] == blank) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
//将数据转换成String, 不能超过length
|
||||
blockContent = String(buffer, 0, end, charset)
|
||||
buffer.copyInto(buffer, 0, end, bufferStart + length)
|
||||
bufferStart = bufferStart + length - end
|
||||
length = end
|
||||
//当前Block下使过的String的指针
|
||||
var seekPos = 0
|
||||
//进行正则匹配
|
||||
val matcher: Matcher = pattern.matcher(blockContent)
|
||||
//如果存在相应章节
|
||||
while (matcher.find()) { //获取匹配到的字符在字符串中的起始位置
|
||||
val chapterStart = matcher.start()
|
||||
//获取章节内容
|
||||
val chapterContent = blockContent.substring(seekPos, chapterStart)
|
||||
val chapterLength = chapterContent.toByteArray(charset).size.toLong()
|
||||
val lastStart = toc.lastOrNull()?.start ?: curOffset
|
||||
if (book.getSplitLongChapter() && curOffset + chapterLength - lastStart > maxLengthWithToc) {
|
||||
toc.lastOrNull()?.let {
|
||||
it.end = it.start
|
||||
it.tag = null
|
||||
}
|
||||
//章节字数太多进行拆分
|
||||
val lastTitle = toc.lastOrNull()?.title
|
||||
val lastTitleLength = lastTitle?.toByteArray(charset)?.size ?: 0
|
||||
val (chapters, wordCount) = analyze(
|
||||
lastStart + lastTitleLength, curOffset + chapterLength
|
||||
)
|
||||
lastTitle?.let {
|
||||
chapters.forEachIndexed { index, bookChapter ->
|
||||
bookChapter.title = "$lastTitle(${index + 1})"
|
||||
}
|
||||
}
|
||||
toc.addAll(chapters)
|
||||
bookWordCount += wordCount
|
||||
//创建当前章节
|
||||
val curChapter = BookChapter()
|
||||
curChapter.title = matcher.group()
|
||||
curChapter.start = curOffset + chapterLength
|
||||
toc.add(curChapter)
|
||||
lastChapterWordCount = 0
|
||||
} else if (seekPos == 0 && chapterStart != 0) {
|
||||
/**
|
||||
* 如果 seekPos == 0 && chapterStart != 0 表示当前block处前面有一段内容
|
||||
* 第一种情况一定是序章 第二种情况是上一个章节的内容
|
||||
*/
|
||||
if (toc.isEmpty()) { //如果当前没有章节,那么就是序章
|
||||
//加入简介
|
||||
if (chapterContent.isNotBlank()) {
|
||||
val qyChapter = BookChapter()
|
||||
qyChapter.title = "前言"
|
||||
qyChapter.start = curOffset
|
||||
qyChapter.end = curOffset + chapterLength
|
||||
qyChapter.wordCount =
|
||||
StringUtils.wordCountFormat(chapterContent.length)
|
||||
toc.add(qyChapter)
|
||||
book.intro = if (chapterContent.length <= 500) {
|
||||
chapterContent
|
||||
} else {
|
||||
chapterContent.substring(0, 500)
|
||||
}
|
||||
}
|
||||
//创建当前章节
|
||||
val curChapter = BookChapter()
|
||||
curChapter.title = matcher.group()
|
||||
curChapter.start = curOffset + chapterLength
|
||||
toc.add(curChapter)
|
||||
} else { //否则就block分割之后,上一个章节的剩余内容
|
||||
//获取上一章节
|
||||
val lastChapter = toc.last()
|
||||
lastChapter.isVolume =
|
||||
chapterContent.substringAfter(lastChapter.title).isBlank()
|
||||
//将当前段落添加上一章去
|
||||
lastChapter.end = lastChapter.end!! + chapterLength
|
||||
lastChapterWordCount += chapterContent.length
|
||||
lastChapter.wordCount =
|
||||
StringUtils.wordCountFormat(lastChapterWordCount)
|
||||
//创建当前章节
|
||||
val curChapter = BookChapter()
|
||||
curChapter.title = matcher.group()
|
||||
curChapter.start = lastChapter.end
|
||||
toc.add(curChapter)
|
||||
}
|
||||
bookWordCount += chapterContent.length
|
||||
lastChapterWordCount = 0
|
||||
} else {
|
||||
if (toc.isNotEmpty()) { //获取章节内容
|
||||
//获取上一章节
|
||||
val lastChapter = toc.last()
|
||||
lastChapter.isVolume =
|
||||
chapterContent.substringAfter(lastChapter.title).isBlank()
|
||||
lastChapter.end =
|
||||
lastChapter.start!! + chapterLength
|
||||
lastChapter.wordCount =
|
||||
StringUtils.wordCountFormat(chapterContent.length)
|
||||
//创建当前章节
|
||||
val curChapter = BookChapter()
|
||||
curChapter.title = matcher.group()
|
||||
curChapter.start = lastChapter.end
|
||||
toc.add(curChapter)
|
||||
} else { //如果章节不存在则创建章节
|
||||
val curChapter = BookChapter()
|
||||
curChapter.title = matcher.group()
|
||||
curChapter.start = curOffset
|
||||
curChapter.end = curOffset
|
||||
curChapter.wordCount =
|
||||
StringUtils.wordCountFormat(chapterContent.length)
|
||||
toc.add(curChapter)
|
||||
}
|
||||
bookWordCount += chapterContent.length
|
||||
lastChapterWordCount = 0
|
||||
}
|
||||
//设置指针偏移
|
||||
seekPos += chapterContent.length
|
||||
}
|
||||
val wordCount = blockContent.length - seekPos
|
||||
bookWordCount += wordCount
|
||||
lastChapterWordCount += wordCount
|
||||
//block的偏移点
|
||||
curOffset += length.toLong()
|
||||
//设置上一章的结尾
|
||||
toc.lastOrNull()?.let {
|
||||
it.end = curOffset
|
||||
it.wordCount = StringUtils.wordCountFormat(lastChapterWordCount)
|
||||
}
|
||||
}
|
||||
toc.lastOrNull()?.let { chapter ->
|
||||
//章节字数太多进行拆分
|
||||
if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) {
|
||||
val end = chapter.end!!
|
||||
chapter.end = chapter.start
|
||||
chapter.tag = null
|
||||
val lastTitle = chapter.title
|
||||
val lastTitleLength = lastTitle.toByteArray(charset).size
|
||||
val (chapters, _) = analyze(
|
||||
chapter.start!! + lastTitleLength, end
|
||||
)
|
||||
chapters.forEachIndexed { index, bookChapter ->
|
||||
bookChapter.title = "$lastTitle(${index + 1})"
|
||||
}
|
||||
toc.addAll(chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
System.gc()
|
||||
System.runFinalization()
|
||||
return toc to bookWordCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 无规则拆分目录
|
||||
*/
|
||||
private fun analyze(
|
||||
fileStart: Long = 0L, fileEnd: Long = Long.MAX_VALUE
|
||||
): Pair<ArrayList<BookChapter>, Int> {
|
||||
val toc = arrayListOf<BookChapter>()
|
||||
var bookWordCount = 0
|
||||
LocalBook.getBookInputStream(book).use { bis ->
|
||||
//block的个数
|
||||
var blockPos = 0
|
||||
//加载章节
|
||||
var curOffset: Long = 0
|
||||
var chapterPos = 0
|
||||
//读取的长度
|
||||
var length = 0
|
||||
var lastChapterWordCount = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bufferStart = 3
|
||||
if (fileStart == 0L) {
|
||||
bis.read(buffer, 0, 3)
|
||||
if (Utf8BomUtils.hasBom(buffer)) {
|
||||
bufferStart = 0
|
||||
curOffset = 3
|
||||
}
|
||||
} else {
|
||||
bis.skip(fileStart)
|
||||
curOffset = fileStart
|
||||
bufferStart = 0
|
||||
}
|
||||
//获取文件中的数据到buffer,直到没有数据为止
|
||||
while (fileEnd - curOffset - bufferStart > 0 && bis.read(
|
||||
buffer, bufferStart, min(
|
||||
(bufferSize - bufferStart).toLong(), fileEnd - curOffset - bufferStart
|
||||
).toInt()
|
||||
).also { length = it } > 0
|
||||
) {
|
||||
blockPos++
|
||||
//章节在buffer的偏移量
|
||||
var chapterOffset = 0
|
||||
//当前剩余可分配的长度
|
||||
length += bufferStart
|
||||
var strLength = length
|
||||
//分章的位置
|
||||
chapterPos = 0
|
||||
while (strLength > 0) {
|
||||
chapterPos++
|
||||
//是否长度超过一章
|
||||
if (strLength > maxLengthWithNoToc) { //在buffer中一章的终止点
|
||||
var end = length
|
||||
//寻找换行符作为终止点
|
||||
for (i in chapterOffset + maxLengthWithNoToc until length) {
|
||||
if (buffer[i] == blank) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
val content = String(buffer, chapterOffset, end - chapterOffset, charset)
|
||||
bookWordCount += content.length
|
||||
lastChapterWordCount = content.length
|
||||
val chapter = BookChapter()
|
||||
chapter.title = "第${blockPos}章($chapterPos)"
|
||||
chapter.start = toc.lastOrNull()?.end ?: curOffset
|
||||
chapter.end = chapter.start!! + end - chapterOffset
|
||||
chapter.wordCount = StringUtils.wordCountFormat(content.length)
|
||||
toc.add(chapter)
|
||||
//减去已经被分配的长度
|
||||
strLength -= (end - chapterOffset)
|
||||
//设置偏移的位置
|
||||
chapterOffset = end
|
||||
} else {
|
||||
buffer.copyInto(buffer, 0, length - strLength, length)
|
||||
length -= strLength
|
||||
bufferStart = strLength
|
||||
strLength = 0
|
||||
}
|
||||
}
|
||||
//block的偏移点
|
||||
curOffset += length.toLong()
|
||||
}
|
||||
//设置结尾章节
|
||||
val content = String(buffer, 0, bufferStart, charset)
|
||||
bookWordCount += content.length
|
||||
if (bufferStart > 100 || toc.isEmpty()) {
|
||||
val chapter = BookChapter()
|
||||
chapter.title = "第${blockPos}章(${chapterPos})"
|
||||
chapter.start = toc.lastOrNull()?.end ?: curOffset
|
||||
chapter.end = chapter.start!! + bufferStart
|
||||
chapter.wordCount = StringUtils.wordCountFormat(content.length)
|
||||
toc.add(chapter)
|
||||
} else {
|
||||
val wordCount = lastChapterWordCount + content.length
|
||||
toc.lastOrNull()?.let {
|
||||
it.end = it.end!! + bufferStart
|
||||
it.wordCount = StringUtils.wordCountFormat(wordCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toc to bookWordCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合适的目录规则
|
||||
*/
|
||||
private fun getTocRule(content: String): Pattern? {
|
||||
val rules = getTocRules().reversed()
|
||||
var maxNum = 1
|
||||
var tocPattern: Pattern? = null
|
||||
for (tocRule in rules) {
|
||||
val pattern = try {
|
||||
tocRule.rule.toPattern(Pattern.MULTILINE)
|
||||
} catch (e: PatternSyntaxException) {
|
||||
AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e)
|
||||
continue
|
||||
}
|
||||
val matcher = pattern.matcher(content)
|
||||
var start = 0
|
||||
var num = 0
|
||||
while (matcher.find()) {
|
||||
if (start == 0 || matcher.start() - start > 1000) {
|
||||
num++
|
||||
start = matcher.end()
|
||||
}
|
||||
}
|
||||
if (num >= maxNum) {
|
||||
maxNum = num
|
||||
tocPattern = pattern
|
||||
}
|
||||
}
|
||||
return tocPattern
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的目录规则
|
||||
*/
|
||||
private fun getTocRules(): List<TxtTocRule> {
|
||||
var rules = appDb.txtTocRuleDao.enabled
|
||||
if (appDb.txtTocRuleDao.count == 0) {
|
||||
rules = DefaultData.txtTocRules.apply {
|
||||
appDb.txtTocRuleDao.insert(*this.toTypedArray())
|
||||
}.filter {
|
||||
it.enable
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package io.legato.kazusa.model.localBook
|
||||
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.utils.DebugLog
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import me.ag2s.umdlib.domain.UmdBook
|
||||
import me.ag2s.umdlib.umd.UmdReader
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
class UmdFile(var book: Book) {
|
||||
companion object : BaseLocalBookParse {
|
||||
private var uFile: UmdFile? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getUFile(book: Book): UmdFile {
|
||||
if (uFile == null || uFile?.book?.bookUrl != book.bookUrl) {
|
||||
uFile = UmdFile(book)
|
||||
return uFile!!
|
||||
}
|
||||
uFile?.book = book
|
||||
return uFile!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getChapterList(book: Book): ArrayList<BookChapter> {
|
||||
return getUFile(book).getChapterList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getContent(book: Book, chapter: BookChapter): String? {
|
||||
return getUFile(book).getContent(chapter)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun getImage(
|
||||
book: Book,
|
||||
href: String
|
||||
): InputStream? {
|
||||
return getUFile(book).getImage(href)
|
||||
}
|
||||
|
||||
|
||||
@Synchronized
|
||||
override fun upBookInfo(book: Book) {
|
||||
return getUFile(book).upBookInfo()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var umdBook: UmdBook? = null
|
||||
get() {
|
||||
if (field != null) {
|
||||
return field
|
||||
}
|
||||
field = readUmd()
|
||||
return field
|
||||
}
|
||||
|
||||
init {
|
||||
upBookCover(true)
|
||||
}
|
||||
|
||||
private fun readUmd(): UmdBook? {
|
||||
val input = LocalBook.getBookInputStream(book)
|
||||
return UmdReader().read(input)
|
||||
}
|
||||
|
||||
private fun upBookCover(fastCheck: Boolean = false) {
|
||||
try {
|
||||
umdBook?.let {
|
||||
if (book.coverUrl.isNullOrEmpty()) {
|
||||
book.coverUrl = LocalBook.getCoverPath(book)
|
||||
}
|
||||
if (fastCheck && File(book.coverUrl!!).exists()) {
|
||||
return
|
||||
}
|
||||
FileUtils.writeBytes(book.coverUrl!!, it.cover.coverData)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookInfo() {
|
||||
if (umdBook == null) {
|
||||
uFile = null
|
||||
book.intro = "书籍导入异常"
|
||||
} else {
|
||||
upBookCover()
|
||||
val hd = umdBook!!.header
|
||||
book.name = hd.title
|
||||
book.author = hd.author
|
||||
book.kind = hd.bookType
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContent(chapter: BookChapter): String? {
|
||||
return umdBook?.chapters?.getContentString(chapter.index)
|
||||
}
|
||||
|
||||
private fun getChapterList(): ArrayList<BookChapter> {
|
||||
val chapterList = ArrayList<BookChapter>()
|
||||
umdBook?.chapters?.titles?.forEachIndexed { index, _ ->
|
||||
val title = umdBook!!.chapters.getTitle(index)
|
||||
val chapter = BookChapter()
|
||||
chapter.title = title
|
||||
chapter.index = index
|
||||
chapter.bookUrl = book.bookUrl
|
||||
chapter.url = index.toString()
|
||||
DebugLog.d(javaClass.name, chapter.url)
|
||||
chapterList.add(chapter)
|
||||
}
|
||||
return chapterList
|
||||
}
|
||||
|
||||
private fun getImage(@Suppress("UNUSED_PARAMETER") href: String): InputStream? {
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.legato.kazusa.model.remote
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import io.legato.kazusa.lib.webdav.WebDavFile
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
|
||||
@Keep
|
||||
data class RemoteBook(
|
||||
val filename: String,
|
||||
val path: String,
|
||||
val size: Long,
|
||||
val lastModify: Long,
|
||||
var contentType: String = "folder",
|
||||
var isOnBookShelf: Boolean = false
|
||||
) {
|
||||
|
||||
val isDir get() = contentType == "folder"
|
||||
|
||||
constructor(webDavFile: WebDavFile) : this(
|
||||
webDavFile.displayName,
|
||||
webDavFile.path,
|
||||
webDavFile.size,
|
||||
webDavFile.lastModify
|
||||
) {
|
||||
if (!webDavFile.isDir) {
|
||||
contentType = webDavFile.displayName.substringAfterLast(".")
|
||||
isOnBookShelf = LocalBook.isOnBookShelf(webDavFile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.legato.kazusa.model.remote
|
||||
|
||||
import android.net.Uri
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
|
||||
abstract class RemoteBookManager {
|
||||
|
||||
/**
|
||||
* 获取书籍列表
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
abstract suspend fun getRemoteBookList(path: String): MutableList<RemoteBook>
|
||||
|
||||
/**
|
||||
* 根据书籍地址获取书籍信息
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
abstract suspend fun getRemoteBook(path: String): RemoteBook?
|
||||
|
||||
/**
|
||||
* @return Uri:下载到本地的路径
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
abstract suspend fun downloadRemoteBook(remoteBook: RemoteBook): Uri
|
||||
|
||||
/**
|
||||
* 上传书籍
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
abstract suspend fun upload(book: Book)
|
||||
|
||||
/**
|
||||
* 删除书籍
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
abstract suspend fun delete(remoteBookUrl: String)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package io.legato.kazusa.model.remote
|
||||
|
||||
import android.net.Uri
|
||||
import io.legato.kazusa.constant.AppPattern.archiveFileRegex
|
||||
import io.legato.kazusa.constant.AppPattern.bookFileRegex
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.update
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.lib.webdav.Authorization
|
||||
import io.legato.kazusa.lib.webdav.WebDav
|
||||
import io.legato.kazusa.lib.webdav.WebDavFile
|
||||
import io.legato.kazusa.model.analyzeRule.CustomUrl
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class RemoteBookWebDav(
|
||||
val rootBookUrl: String,
|
||||
val authorization: Authorization,
|
||||
val serverID: Long? = null
|
||||
) : RemoteBookManager() {
|
||||
|
||||
init {
|
||||
runBlocking {
|
||||
WebDav(rootBookUrl, authorization).makeAsDir()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Throws(Exception::class)
|
||||
override suspend fun getRemoteBookList(path: String): MutableList<RemoteBook> {
|
||||
if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用")
|
||||
val remoteBooks = mutableListOf<RemoteBook>()
|
||||
//读取文件列表
|
||||
val remoteWebDavFileList: List<WebDavFile> = WebDav(path, authorization).listFiles()
|
||||
//转化远程文件信息到本地对象
|
||||
remoteWebDavFileList.forEach { webDavFile ->
|
||||
if (webDavFile.isDir
|
||||
|| bookFileRegex.matches(webDavFile.displayName)
|
||||
|| archiveFileRegex.matches(webDavFile.displayName)
|
||||
) {
|
||||
//扩展名符合阅读的格式则认为是书籍
|
||||
remoteBooks.add(RemoteBook(webDavFile))
|
||||
}
|
||||
}
|
||||
return remoteBooks
|
||||
}
|
||||
|
||||
override suspend fun getRemoteBook(path: String): RemoteBook? {
|
||||
if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用")
|
||||
val webDavFile = WebDav(path, authorization).getWebDavFile()
|
||||
?: return null
|
||||
return RemoteBook(webDavFile)
|
||||
}
|
||||
|
||||
override suspend fun downloadRemoteBook(remoteBook: RemoteBook): Uri {
|
||||
AppConfig.defaultBookTreeUri
|
||||
?: throw NoStackTraceException("没有设置书籍保存位置!")
|
||||
if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用")
|
||||
val webdav = WebDav(remoteBook.path, authorization)
|
||||
return webdav.downloadInputStream().let { inputStream ->
|
||||
LocalBook.saveBookFile(inputStream, remoteBook.filename)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upload(book: Book) {
|
||||
if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用")
|
||||
val localBookUri = Uri.parse(book.bookUrl)
|
||||
val putUrl = "$rootBookUrl${book.originName}"
|
||||
val webDav = WebDav(putUrl, authorization)
|
||||
if (localBookUri.isContentScheme()) {
|
||||
webDav.upload(localBookUri)
|
||||
} else {
|
||||
webDav.upload(localBookUri.path!!)
|
||||
}
|
||||
book.origin = BookType.webDavTag + CustomUrl(putUrl)
|
||||
.putAttribute("serverID", serverID)
|
||||
.toString()
|
||||
book.update()
|
||||
}
|
||||
|
||||
override suspend fun delete(remoteBookUrl: String) {
|
||||
if (!NetworkUtils.isAvailable()) throw NoStackTraceException("网络不可用")
|
||||
WebDav(remoteBookUrl, authorization).delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package io.legato.kazusa.model.rss
|
||||
|
||||
import io.legato.kazusa.data.entities.RssArticle
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.http.StrResponse
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.model.analyzeRule.RuleData
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
object Rss {
|
||||
|
||||
fun getArticles(
|
||||
scope: CoroutineScope,
|
||||
sortName: String,
|
||||
sortUrl: String,
|
||||
rssSource: RssSource,
|
||||
page: Int,
|
||||
context: CoroutineContext = Dispatchers.IO
|
||||
): Coroutine<Pair<MutableList<RssArticle>, String?>> {
|
||||
return Coroutine.async(scope, context) {
|
||||
getArticlesAwait(sortName, sortUrl, rssSource, page)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getArticlesAwait(
|
||||
sortName: String,
|
||||
sortUrl: String,
|
||||
rssSource: RssSource,
|
||||
page: Int,
|
||||
): Pair<MutableList<RssArticle>, String?> {
|
||||
val ruleData = RuleData()
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
sortUrl,
|
||||
page = page,
|
||||
source = rssSource,
|
||||
ruleData = ruleData,
|
||||
coroutineContext = coroutineContext,
|
||||
hasLoginHeader = false
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait()
|
||||
checkRedirect(rssSource, res)
|
||||
return RssParserByRule.parseXML(sortName, sortUrl, res.url, res.body, rssSource, ruleData)
|
||||
}
|
||||
|
||||
fun getContent(
|
||||
scope: CoroutineScope,
|
||||
rssArticle: RssArticle,
|
||||
ruleContent: String,
|
||||
rssSource: RssSource,
|
||||
context: CoroutineContext = Dispatchers.IO
|
||||
): Coroutine<String> {
|
||||
return Coroutine.async(scope, context) {
|
||||
getContentAwait(rssArticle, ruleContent, rssSource)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContentAwait(
|
||||
rssArticle: RssArticle,
|
||||
ruleContent: String,
|
||||
rssSource: RssSource,
|
||||
): String {
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
rssArticle.link,
|
||||
baseUrl = rssArticle.origin,
|
||||
source = rssSource,
|
||||
ruleData = rssArticle,
|
||||
coroutineContext = coroutineContext,
|
||||
hasLoginHeader = false
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait()
|
||||
checkRedirect(rssSource, res)
|
||||
Debug.log(rssSource.sourceUrl, "≡获取成功:${rssSource.sourceUrl}")
|
||||
Debug.log(rssSource.sourceUrl, res.body ?: "", state = 20)
|
||||
val analyzeRule = AnalyzeRule(rssArticle, rssSource)
|
||||
analyzeRule.setContent(res.body)
|
||||
.setBaseUrl(NetworkUtils.getAbsoluteURL(rssArticle.origin, rssArticle.link))
|
||||
.setCoroutineContext(coroutineContext)
|
||||
.setRedirectUrl(res.url)
|
||||
return analyzeRule.getString(ruleContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重定向
|
||||
*/
|
||||
private fun checkRedirect(rssSource: RssSource, response: StrResponse) {
|
||||
response.raw.priorResponse?.let {
|
||||
if (it.isRedirect) {
|
||||
Debug.log(rssSource.sourceUrl, "≡检测到重定向(${it.code})")
|
||||
Debug.log(rssSource.sourceUrl, "┌重定向后地址")
|
||||
Debug.log(rssSource.sourceUrl, "└${response.url}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package io.legato.kazusa.model.rss
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.data.entities.RssArticle
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setRuleData
|
||||
import io.legato.kazusa.model.analyzeRule.RuleData
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import splitties.init.appCtx
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Keep
|
||||
object RssParserByRule {
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun parseXML(
|
||||
sortName: String,
|
||||
sortUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String?,
|
||||
rssSource: RssSource,
|
||||
ruleData: RuleData
|
||||
): Pair<MutableList<RssArticle>, String?> {
|
||||
val sourceUrl = rssSource.sourceUrl
|
||||
var nextUrl: String? = null
|
||||
if (body.isNullOrBlank()) {
|
||||
throw NoStackTraceException(
|
||||
appCtx.getString(R.string.error_get_web_content, rssSource.sourceUrl)
|
||||
)
|
||||
}
|
||||
Debug.log(sourceUrl, "≡获取成功:$sourceUrl")
|
||||
Debug.log(sourceUrl, body, state = 10)
|
||||
var ruleArticles = rssSource.ruleArticles
|
||||
if (ruleArticles.isNullOrBlank()) {
|
||||
Debug.log(sourceUrl, "⇒列表规则为空, 使用默认规则解析")
|
||||
return RssParserDefault.parseXML(sortName, body, sourceUrl)
|
||||
} else {
|
||||
val articleList = mutableListOf<RssArticle>()
|
||||
val analyzeRule = AnalyzeRule(ruleData, rssSource)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
analyzeRule.setContent(body).setBaseUrl(sortUrl)
|
||||
analyzeRule.setRedirectUrl(redirectUrl)
|
||||
var reverse = false
|
||||
if (ruleArticles.startsWith("-")) {
|
||||
reverse = true
|
||||
ruleArticles = ruleArticles.substring(1)
|
||||
}
|
||||
Debug.log(sourceUrl, "┌获取列表")
|
||||
val collections = analyzeRule.getElements(ruleArticles)
|
||||
Debug.log(sourceUrl, "└列表大小:${collections.size}")
|
||||
if (!rssSource.ruleNextPage.isNullOrEmpty()) {
|
||||
Debug.log(sourceUrl, "┌获取下一页链接")
|
||||
if (rssSource.ruleNextPage!!.uppercase(Locale.getDefault()) == "PAGE") {
|
||||
nextUrl = sortUrl
|
||||
} else {
|
||||
nextUrl = analyzeRule.getString(rssSource.ruleNextPage)
|
||||
if (nextUrl.isNotEmpty()) {
|
||||
nextUrl = NetworkUtils.getAbsoluteURL(sortUrl, nextUrl)
|
||||
}
|
||||
}
|
||||
Debug.log(sourceUrl, "└$nextUrl")
|
||||
}
|
||||
val ruleTitle = analyzeRule.splitSourceRule(rssSource.ruleTitle)
|
||||
val rulePubDate = analyzeRule.splitSourceRule(rssSource.rulePubDate)
|
||||
val ruleDescription = analyzeRule.splitSourceRule(rssSource.ruleDescription)
|
||||
val ruleImage = analyzeRule.splitSourceRule(rssSource.ruleImage)
|
||||
val ruleLink = analyzeRule.splitSourceRule(rssSource.ruleLink)
|
||||
val variable = ruleData.getVariable()
|
||||
for ((index, item) in collections.withIndex()) {
|
||||
getItem(
|
||||
sourceUrl, item, analyzeRule, variable, index == 0,
|
||||
ruleTitle, rulePubDate, ruleDescription, ruleImage, ruleLink
|
||||
)?.let {
|
||||
it.sort = sortName
|
||||
it.origin = sourceUrl
|
||||
articleList.add(it)
|
||||
}
|
||||
}
|
||||
if (reverse) {
|
||||
articleList.reverse()
|
||||
}
|
||||
return Pair(articleList, nextUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getItem(
|
||||
sourceUrl: String,
|
||||
item: Any,
|
||||
analyzeRule: AnalyzeRule,
|
||||
variable: String?,
|
||||
log: Boolean,
|
||||
ruleTitle: List<AnalyzeRule.SourceRule>,
|
||||
rulePubDate: List<AnalyzeRule.SourceRule>,
|
||||
ruleDescription: List<AnalyzeRule.SourceRule>,
|
||||
ruleImage: List<AnalyzeRule.SourceRule>,
|
||||
ruleLink: List<AnalyzeRule.SourceRule>
|
||||
): RssArticle? {
|
||||
val rssArticle = RssArticle(variable = variable)
|
||||
analyzeRule.setRuleData(rssArticle)
|
||||
analyzeRule.setContent(item)
|
||||
Debug.log(sourceUrl, "┌获取标题", log)
|
||||
rssArticle.title = analyzeRule.getString(ruleTitle)
|
||||
Debug.log(sourceUrl, "└${rssArticle.title}", log)
|
||||
Debug.log(sourceUrl, "┌获取时间", log)
|
||||
rssArticle.pubDate = analyzeRule.getString(rulePubDate)
|
||||
Debug.log(sourceUrl, "└${rssArticle.pubDate}", log)
|
||||
Debug.log(sourceUrl, "┌获取描述", log)
|
||||
if (ruleDescription.isEmpty()) {
|
||||
rssArticle.description = null
|
||||
Debug.log(sourceUrl, "└描述规则为空,将会解析内容页", log)
|
||||
} else {
|
||||
rssArticle.description = analyzeRule.getString(ruleDescription)
|
||||
Debug.log(sourceUrl, "└${rssArticle.description}", log)
|
||||
}
|
||||
Debug.log(sourceUrl, "┌获取图片url", log)
|
||||
rssArticle.image = analyzeRule.getString(ruleImage, isUrl = true)
|
||||
Debug.log(sourceUrl, "└${rssArticle.image}", log)
|
||||
Debug.log(sourceUrl, "┌获取文章链接", log)
|
||||
rssArticle.link = NetworkUtils.getAbsoluteURL(sourceUrl, analyzeRule.getString(ruleLink))
|
||||
Debug.log(sourceUrl, "└${rssArticle.link}", log)
|
||||
if (rssArticle.title.isBlank()) {
|
||||
return null
|
||||
}
|
||||
return rssArticle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package io.legato.kazusa.model.rss
|
||||
|
||||
import io.legato.kazusa.data.entities.RssArticle
|
||||
import io.legato.kazusa.model.Debug
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.IOException
|
||||
import java.io.StringReader
|
||||
|
||||
@Suppress("unused")
|
||||
object RssParserDefault {
|
||||
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
fun parseXML(
|
||||
sortName: String,
|
||||
xml: String,
|
||||
sourceUrl: String
|
||||
): Pair<MutableList<RssArticle>, String?> {
|
||||
|
||||
val articleList = mutableListOf<RssArticle>()
|
||||
var currentArticle = RssArticle()
|
||||
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = false
|
||||
|
||||
val xmlPullParser = factory.newPullParser()
|
||||
xmlPullParser.setInput(StringReader(xml))
|
||||
|
||||
// A flag just to be sure of the correct parsing
|
||||
var insideItem = false
|
||||
|
||||
var eventType = xmlPullParser.eventType
|
||||
|
||||
// Start parsing the xml
|
||||
loop@ while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
|
||||
// Start parsing the item
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
when {
|
||||
xmlPullParser.name.equals(RSS_ITEM, true) ->
|
||||
insideItem = true
|
||||
xmlPullParser.name.equals(RSS_ITEM_TITLE, true) ->
|
||||
if (insideItem) currentArticle.title = xmlPullParser.nextText().trim()
|
||||
xmlPullParser.name.equals(RSS_ITEM_LINK, true) ->
|
||||
if (insideItem) currentArticle.link = xmlPullParser.nextText().trim()
|
||||
xmlPullParser.name.equals(RSS_ITEM_THUMBNAIL, true) ->
|
||||
if (insideItem) currentArticle.image =
|
||||
xmlPullParser.getAttributeValue(null, RSS_ITEM_URL)
|
||||
xmlPullParser.name.equals(RSS_ITEM_ENCLOSURE, true) ->
|
||||
if (insideItem) {
|
||||
val type =
|
||||
xmlPullParser.getAttributeValue(null, RSS_ITEM_TYPE)
|
||||
if (type != null && type.contains("image/")) {
|
||||
currentArticle.image =
|
||||
xmlPullParser.getAttributeValue(null, RSS_ITEM_URL)
|
||||
}
|
||||
}
|
||||
xmlPullParser.name
|
||||
.equals(RSS_ITEM_DESCRIPTION, true) ->
|
||||
if (insideItem) {
|
||||
val description = xmlPullParser.nextText()
|
||||
currentArticle.description = description.trim()
|
||||
if (currentArticle.image == null) {
|
||||
currentArticle.image = getImageUrl(description)
|
||||
}
|
||||
}
|
||||
xmlPullParser.name.equals(RSS_ITEM_CONTENT, true) ->
|
||||
if (insideItem) {
|
||||
val content = xmlPullParser.nextText().trim()
|
||||
currentArticle.content = content
|
||||
if (currentArticle.image == null) {
|
||||
currentArticle.image = getImageUrl(content)
|
||||
}
|
||||
}
|
||||
xmlPullParser.name
|
||||
.equals(RSS_ITEM_PUB_DATE, true) ->
|
||||
if (insideItem) {
|
||||
val nextTokenType = xmlPullParser.next()
|
||||
if (nextTokenType == XmlPullParser.TEXT) {
|
||||
currentArticle.pubDate = xmlPullParser.text.trim()
|
||||
}
|
||||
// Skip to be able to find date inside 'tag' tag
|
||||
continue@loop
|
||||
}
|
||||
xmlPullParser.name.equals(RSS_ITEM_TIME, true) ->
|
||||
if (insideItem) currentArticle.pubDate = xmlPullParser.nextText()
|
||||
}
|
||||
} else if (eventType == XmlPullParser.END_TAG
|
||||
&& xmlPullParser.name.equals("item", true)
|
||||
) {
|
||||
// The item is correctly parsed
|
||||
insideItem = false
|
||||
currentArticle.origin = sourceUrl
|
||||
currentArticle.sort = sortName
|
||||
articleList.add(currentArticle)
|
||||
currentArticle = RssArticle()
|
||||
}
|
||||
eventType = xmlPullParser.next()
|
||||
}
|
||||
articleList.firstOrNull()?.let {
|
||||
Debug.log(sourceUrl, "┌获取标题")
|
||||
Debug.log(sourceUrl, "└${it.title}")
|
||||
Debug.log(sourceUrl, "┌获取时间")
|
||||
Debug.log(sourceUrl, "└${it.pubDate}")
|
||||
Debug.log(sourceUrl, "┌获取描述")
|
||||
Debug.log(sourceUrl, "└${it.description}")
|
||||
Debug.log(sourceUrl, "┌获取图片url")
|
||||
Debug.log(sourceUrl, "└${it.image}")
|
||||
Debug.log(sourceUrl, "┌获取文章链接")
|
||||
Debug.log(sourceUrl, "└${it.link}")
|
||||
}
|
||||
return Pair(articleList, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first img tag and get the src as featured image
|
||||
*
|
||||
* @param input The content in which to search for the tag
|
||||
* @return The url, if there is one
|
||||
*/
|
||||
private fun getImageUrl(input: String): String? {
|
||||
|
||||
var url: String? = null
|
||||
val patternImg = "(<img [^>]*>)".toPattern()
|
||||
val matcherImg = patternImg.matcher(input)
|
||||
if (matcherImg.find()) {
|
||||
val imgTag = matcherImg.group(1)
|
||||
val patternLink = "src\\s*=\\s*\"([^\"]+)\"".toPattern()
|
||||
val matcherLink = patternLink.matcher(imgTag!!)
|
||||
if (matcherLink.find()) {
|
||||
url = matcherLink.group(1)!!.trim()
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private const val RSS_ITEM = "item"
|
||||
private const val RSS_ITEM_TITLE = "title"
|
||||
private const val RSS_ITEM_LINK = "link"
|
||||
private const val RSS_ITEM_CATEGORY = "category"
|
||||
private const val RSS_ITEM_THUMBNAIL = "media:thumbnail"
|
||||
private const val RSS_ITEM_ENCLOSURE = "enclosure"
|
||||
private const val RSS_ITEM_DESCRIPTION = "description"
|
||||
private const val RSS_ITEM_CONTENT = "content:encoded"
|
||||
private const val RSS_ITEM_PUB_DATE = "pubDate"
|
||||
private const val RSS_ITEM_TIME = "time"
|
||||
private const val RSS_ITEM_URL = "url"
|
||||
private const val RSS_ITEM_TYPE = "type"
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import android.text.TextUtils
|
||||
import com.script.ScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.rule.TocRule
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.exception.TocEmptyException
|
||||
import io.legato.kazusa.help.book.ContentProcessor
|
||||
import io.legato.kazusa.help.book.simulatedTotalChapterNum
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setChapter
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.isTrue
|
||||
import io.legato.kazusa.utils.mapAsync
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.mozilla.javascript.Context
|
||||
import splitties.init.appCtx
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* 获取目录
|
||||
*/
|
||||
object BookChapterList {
|
||||
|
||||
suspend fun analyzeChapterList(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String?
|
||||
): List<BookChapter> {
|
||||
body ?: throw NoStackTraceException(
|
||||
appCtx.getString(R.string.error_get_web_content, baseUrl)
|
||||
)
|
||||
val chapterList = ArrayList<BookChapter>()
|
||||
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
|
||||
Debug.log(bookSource.bookSourceUrl, body, state = 30)
|
||||
val tocRule = bookSource.getTocRule()
|
||||
val nextUrlList = arrayListOf(redirectUrl)
|
||||
var reverse = false
|
||||
var listRule = tocRule.chapterList ?: ""
|
||||
if (listRule.startsWith("-")) {
|
||||
reverse = true
|
||||
listRule = listRule.substring(1)
|
||||
}
|
||||
if (listRule.startsWith("+")) {
|
||||
listRule = listRule.substring(1)
|
||||
}
|
||||
var chapterData =
|
||||
analyzeChapterList(
|
||||
book, baseUrl, redirectUrl, body,
|
||||
tocRule, listRule, bookSource, log = true
|
||||
)
|
||||
chapterList.addAll(chapterData.first)
|
||||
when (chapterData.second.size) {
|
||||
0 -> Unit
|
||||
1 -> {
|
||||
var nextUrl = chapterData.second[0]
|
||||
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
|
||||
nextUrlList.add(nextUrl)
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = nextUrl,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait() //控制并发访问
|
||||
res.body?.let { nextBody ->
|
||||
chapterData = analyzeChapterList(
|
||||
book, nextUrl, nextUrl,
|
||||
nextBody, tocRule, listRule, bookSource
|
||||
)
|
||||
nextUrl = chapterData.second.firstOrNull() ?: ""
|
||||
chapterList.addAll(chapterData.first)
|
||||
}
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "◇目录总页数:${nextUrlList.size}")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Debug.log(
|
||||
bookSource.bookSourceUrl,
|
||||
"◇并发解析目录,总页数:${chapterData.second.size}"
|
||||
)
|
||||
flow {
|
||||
for (urlStr in chapterData.second) {
|
||||
emit(urlStr)
|
||||
}
|
||||
}.mapAsync(AppConfig.threadCount) { urlStr ->
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = urlStr,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait() //控制并发访问
|
||||
analyzeChapterList(
|
||||
book, urlStr, res.url,
|
||||
res.body!!, tocRule, listRule, bookSource, false
|
||||
).first
|
||||
}.collect {
|
||||
chapterList.addAll(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chapterList.isEmpty()) {
|
||||
throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty))
|
||||
}
|
||||
if (!reverse) {
|
||||
chapterList.reverse()
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
//去重
|
||||
val lh = LinkedHashSet(chapterList)
|
||||
val list = ArrayList(lh)
|
||||
if (!book.getReverseToc()) {
|
||||
list.reverse()
|
||||
}
|
||||
Debug.log(book.origin, "◇目录总数:${list.size}")
|
||||
coroutineContext.ensureActive()
|
||||
list.forEachIndexed { index, bookChapter ->
|
||||
bookChapter.index = index
|
||||
}
|
||||
val formatJs = tocRule.formatJs
|
||||
if (!formatJs.isNullOrBlank()) {
|
||||
Context.enter().use {
|
||||
val bindings = ScriptBindings()
|
||||
bindings["gInt"] = 0
|
||||
list.forEachIndexed { index, bookChapter ->
|
||||
bindings["index"] = index + 1
|
||||
bindings["chapter"] = bookChapter
|
||||
bindings["title"] = bookChapter.title
|
||||
RhinoScriptEngine.runCatching {
|
||||
eval(formatJs, bindings)?.toString()?.let {
|
||||
bookChapter.title = it
|
||||
}
|
||||
}.onFailure {
|
||||
Debug.log(book.origin, "格式化标题出错, ${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val replaceRules = ContentProcessor.get(book).getTitleReplaceRules()
|
||||
book.durChapterTitle = list.getOrElse(book.durChapterIndex) { list.last() }
|
||||
.getDisplayTitle(replaceRules, book.getUseReplaceRule())
|
||||
if (book.totalChapterNum < list.size) {
|
||||
book.lastCheckCount = list.size - book.totalChapterNum
|
||||
book.latestChapterTime = System.currentTimeMillis()
|
||||
}
|
||||
book.lastCheckTime = System.currentTimeMillis()
|
||||
book.totalChapterNum = list.size
|
||||
book.latestChapterTitle =
|
||||
list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
|
||||
.getDisplayTitle(replaceRules, book.getUseReplaceRule())
|
||||
coroutineContext.ensureActive()
|
||||
getWordCount(list, book)
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun analyzeChapterList(
|
||||
book: Book,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String,
|
||||
tocRule: TocRule,
|
||||
listRule: String,
|
||||
bookSource: BookSource,
|
||||
getNextUrl: Boolean = true,
|
||||
log: Boolean = false
|
||||
): Pair<List<BookChapter>, List<String>> {
|
||||
val analyzeRule = AnalyzeRule(book, bookSource)
|
||||
analyzeRule.setContent(body).setBaseUrl(baseUrl)
|
||||
analyzeRule.setRedirectUrl(redirectUrl)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
//获取目录列表
|
||||
val chapterList = arrayListOf<BookChapter>()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取目录列表", log)
|
||||
val elements = analyzeRule.getElements(listRule)
|
||||
Debug.log(bookSource.bookSourceUrl, "└列表大小:${elements.size}", log)
|
||||
//获取下一页链接
|
||||
val nextUrlList = arrayListOf<String>()
|
||||
val nextTocRule = tocRule.nextTocUrl
|
||||
if (getNextUrl && !nextTocRule.isNullOrEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取目录下一页列表", log)
|
||||
analyzeRule.getStringList(nextTocRule, isUrl = true)?.let {
|
||||
for (item in it) {
|
||||
if (item != redirectUrl) {
|
||||
nextUrlList.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
Debug.log(
|
||||
bookSource.bookSourceUrl,
|
||||
"└" + TextUtils.join(",\n", nextUrlList),
|
||||
log
|
||||
)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
if (elements.isNotEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "┌解析目录列表", log)
|
||||
val nameRule = analyzeRule.splitSourceRule(tocRule.chapterName)
|
||||
val urlRule = analyzeRule.splitSourceRule(tocRule.chapterUrl)
|
||||
val vipRule = analyzeRule.splitSourceRule(tocRule.isVip)
|
||||
val payRule = analyzeRule.splitSourceRule(tocRule.isPay)
|
||||
val upTimeRule = analyzeRule.splitSourceRule(tocRule.updateTime)
|
||||
val isVolumeRule = analyzeRule.splitSourceRule(tocRule.isVolume)
|
||||
elements.forEachIndexed { index, item ->
|
||||
coroutineContext.ensureActive()
|
||||
analyzeRule.setContent(item)
|
||||
val bookChapter = BookChapter(bookUrl = book.bookUrl, baseUrl = redirectUrl)
|
||||
analyzeRule.setChapter(bookChapter)
|
||||
bookChapter.title = analyzeRule.getString(nameRule)
|
||||
bookChapter.url = analyzeRule.getString(urlRule)
|
||||
bookChapter.tag = analyzeRule.getString(upTimeRule)
|
||||
val isVolume = analyzeRule.getString(isVolumeRule)
|
||||
bookChapter.isVolume = false
|
||||
if (isVolume.isTrue()) {
|
||||
bookChapter.isVolume = true
|
||||
}
|
||||
if (bookChapter.url.isEmpty()) {
|
||||
if (bookChapter.isVolume) {
|
||||
bookChapter.url = bookChapter.title + index
|
||||
Debug.log(
|
||||
bookSource.bookSourceUrl,
|
||||
"⇒一级目录${index}未获取到url,使用标题替代"
|
||||
)
|
||||
} else {
|
||||
bookChapter.url = baseUrl
|
||||
Debug.log(
|
||||
bookSource.bookSourceUrl,
|
||||
"⇒目录${index}未获取到url,使用baseUrl替代"
|
||||
)
|
||||
}
|
||||
}
|
||||
if (bookChapter.title.isNotEmpty()) {
|
||||
val isVip = analyzeRule.getString(vipRule)
|
||||
val isPay = analyzeRule.getString(payRule)
|
||||
if (isVip.isTrue()) {
|
||||
bookChapter.isVip = true
|
||||
}
|
||||
if (isPay.isTrue()) {
|
||||
bookChapter.isPay = true
|
||||
}
|
||||
chapterList.add(bookChapter)
|
||||
}
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└目录列表解析完成", log)
|
||||
if (chapterList.isEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "◇章节列表为空", log)
|
||||
} else {
|
||||
Debug.log(bookSource.bookSourceUrl, "≡首章信息", log)
|
||||
Debug.log(bookSource.bookSourceUrl, "◇章节名称:${chapterList[0].title}", log)
|
||||
Debug.log(bookSource.bookSourceUrl, "◇章节链接:${chapterList[0].url}", log)
|
||||
Debug.log(bookSource.bookSourceUrl, "◇章节信息:${chapterList[0].tag}", log)
|
||||
Debug.log(bookSource.bookSourceUrl, "◇是否VIP:${chapterList[0].isVip}", log)
|
||||
Debug.log(bookSource.bookSourceUrl, "◇是否购买:${chapterList[0].isPay}", log)
|
||||
}
|
||||
}
|
||||
return Pair(chapterList, nextUrlList)
|
||||
}
|
||||
|
||||
private fun getWordCount(list: ArrayList<BookChapter>, book: Book) {
|
||||
if (!AppConfig.tocCountWords) {
|
||||
return
|
||||
}
|
||||
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
|
||||
if (chapterList.isNotEmpty()) {
|
||||
val map = chapterList.associateBy({ it.getFileName() }, { it.wordCount })
|
||||
for (bookChapter in list) {
|
||||
val wordCount = map[bookChapter.getFileName()]
|
||||
if (wordCount != null) {
|
||||
bookChapter.wordCount = wordCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.rule.ContentRule
|
||||
import io.legato.kazusa.exception.ContentEmptyException
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setChapter
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setNextChapterUrl
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.mapAsync
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
import splitties.init.appCtx
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* 获取正文
|
||||
*/
|
||||
object BookContent {
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun analyzeContent(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String?,
|
||||
nextChapterUrl: String?,
|
||||
needSave: Boolean = true
|
||||
): String {
|
||||
body ?: throw NoStackTraceException(
|
||||
appCtx.getString(R.string.error_get_web_content, baseUrl)
|
||||
)
|
||||
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
|
||||
Debug.log(bookSource.bookSourceUrl, body, state = 40)
|
||||
val mNextChapterUrl = if (nextChapterUrl.isNullOrEmpty()) {
|
||||
appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url
|
||||
?: appDb.bookChapterDao.getChapter(book.bookUrl, 0)?.url
|
||||
} else {
|
||||
nextChapterUrl
|
||||
}
|
||||
val contentList = arrayListOf<String>()
|
||||
val nextUrlList = arrayListOf(redirectUrl)
|
||||
val contentRule = bookSource.getContentRule()
|
||||
val analyzeRule = AnalyzeRule(book, bookSource)
|
||||
analyzeRule.setContent(body, baseUrl)
|
||||
analyzeRule.setRedirectUrl(redirectUrl)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
analyzeRule.setChapter(bookChapter)
|
||||
analyzeRule.setNextChapterUrl(mNextChapterUrl)
|
||||
coroutineContext.ensureActive()
|
||||
val titleRule = contentRule.title
|
||||
if (!titleRule.isNullOrBlank()) {
|
||||
val title = analyzeRule.runCatching {
|
||||
getString(titleRule)
|
||||
}.onFailure {
|
||||
Debug.log(bookSource.bookSourceUrl, "获取标题出错, ${it.localizedMessage}")
|
||||
}.getOrNull()
|
||||
if (!title.isNullOrBlank()) {
|
||||
bookChapter.title = title
|
||||
bookChapter.titleMD5 = null
|
||||
appDb.bookChapterDao.update(bookChapter)
|
||||
}
|
||||
}
|
||||
var contentData = analyzeContent(
|
||||
book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl
|
||||
)
|
||||
contentList.add(contentData.first)
|
||||
if (contentData.second.size == 1) {
|
||||
var nextUrl = contentData.second[0]
|
||||
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
|
||||
if (!mNextChapterUrl.isNullOrEmpty()
|
||||
&& NetworkUtils.getAbsoluteURL(redirectUrl, nextUrl)
|
||||
== NetworkUtils.getAbsoluteURL(redirectUrl, mNextChapterUrl)
|
||||
) break
|
||||
nextUrlList.add(nextUrl)
|
||||
coroutineContext.ensureActive()
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = nextUrl,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait() //控制并发访问
|
||||
res.body?.let { nextBody ->
|
||||
contentData = analyzeContent(
|
||||
book, nextUrl, res.url, nextBody, contentRule,
|
||||
bookChapter, bookSource, mNextChapterUrl,
|
||||
printLog = false
|
||||
)
|
||||
nextUrl =
|
||||
if (contentData.second.isNotEmpty()) contentData.second[0] else ""
|
||||
contentList.add(contentData.first)
|
||||
Debug.log(bookSource.bookSourceUrl, "第${contentList.size}页完成")
|
||||
}
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}")
|
||||
} else if (contentData.second.size > 1) {
|
||||
Debug.log(bookSource.bookSourceUrl, "◇并发解析正文,总页数:${contentData.second.size}")
|
||||
flow {
|
||||
for (urlStr in contentData.second) {
|
||||
emit(urlStr)
|
||||
}
|
||||
}.mapAsync(AppConfig.threadCount) { urlStr ->
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = urlStr,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
val res = analyzeUrl.getStrResponseAwait() //控制并发访问
|
||||
analyzeContent(
|
||||
book, urlStr, res.url, res.body!!, contentRule,
|
||||
bookChapter, bookSource, mNextChapterUrl,
|
||||
getNextPageUrl = false,
|
||||
printLog = false
|
||||
).first
|
||||
}.collect {
|
||||
coroutineContext.ensureActive()
|
||||
contentList.add(it)
|
||||
}
|
||||
}
|
||||
var contentStr = contentList.joinToString("\n")
|
||||
//全文替换
|
||||
val replaceRegex = contentRule.replaceRegex
|
||||
if (!replaceRegex.isNullOrEmpty()) {
|
||||
contentStr = contentStr.split(AppPattern.LFRegex).joinToString("\n") { it.trim() }
|
||||
contentStr = analyzeRule.getString(replaceRegex, contentStr)
|
||||
contentStr = contentStr.split(AppPattern.LFRegex).joinToString("\n") { " $it" }
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取章节名称")
|
||||
Debug.log(bookSource.bookSourceUrl, "└${bookChapter.title}")
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取正文内容")
|
||||
Debug.log(bookSource.bookSourceUrl, "└\n$contentStr")
|
||||
if (!bookChapter.isVolume && contentStr.isBlank()) {
|
||||
throw ContentEmptyException("内容为空")
|
||||
}
|
||||
if (needSave) {
|
||||
BookHelp.saveContent(bookSource, book, bookChapter, contentStr)
|
||||
}
|
||||
return contentStr
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private suspend fun analyzeContent(
|
||||
book: Book,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String,
|
||||
contentRule: ContentRule,
|
||||
chapter: BookChapter,
|
||||
bookSource: BookSource,
|
||||
nextChapterUrl: String?,
|
||||
getNextPageUrl: Boolean = true,
|
||||
printLog: Boolean = true
|
||||
): Pair<String, List<String>> {
|
||||
val analyzeRule = AnalyzeRule(book, bookSource)
|
||||
analyzeRule.setContent(body, baseUrl)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
val rUrl = analyzeRule.setRedirectUrl(redirectUrl)
|
||||
analyzeRule.setNextChapterUrl(nextChapterUrl)
|
||||
val nextUrlList = arrayListOf<String>()
|
||||
analyzeRule.setChapter(chapter)
|
||||
//获取正文
|
||||
var content = analyzeRule.getString(contentRule.content, unescape = false)
|
||||
content = HtmlFormatter.formatKeepImg(content, rUrl)
|
||||
if (content.indexOf('&') > -1) {
|
||||
content = StringEscapeUtils.unescapeHtml4(content)
|
||||
}
|
||||
//获取下一页链接
|
||||
if (getNextPageUrl) {
|
||||
val nextUrlRule = contentRule.nextContentUrl
|
||||
if (!nextUrlRule.isNullOrEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取正文下一页链接", printLog)
|
||||
analyzeRule.getStringList(nextUrlRule, isUrl = true)?.let {
|
||||
nextUrlList.addAll(it)
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└" + nextUrlList.joinToString(","), printLog)
|
||||
}
|
||||
}
|
||||
return Pair(content, nextUrlList)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import android.text.TextUtils
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.book.isWebFile
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.utils.DebugLog
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.StringUtils.wordCountFormat
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import splitties.init.appCtx
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
|
||||
/**
|
||||
* 获取详情
|
||||
*/
|
||||
object BookInfo {
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun analyzeBookInfo(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
body: String?,
|
||||
canReName: Boolean,
|
||||
) {
|
||||
body ?: throw NoStackTraceException(
|
||||
appCtx.getString(R.string.error_get_web_content, baseUrl)
|
||||
)
|
||||
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
|
||||
Debug.log(bookSource.bookSourceUrl, body, state = 20)
|
||||
val analyzeRule = AnalyzeRule(book, bookSource)
|
||||
analyzeRule.setContent(body).setBaseUrl(baseUrl)
|
||||
analyzeRule.setRedirectUrl(redirectUrl)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
analyzeBookInfo(book, body, analyzeRule, bookSource, baseUrl, redirectUrl, canReName)
|
||||
}
|
||||
|
||||
suspend fun analyzeBookInfo(
|
||||
book: Book,
|
||||
body: String,
|
||||
analyzeRule: AnalyzeRule,
|
||||
bookSource: BookSource,
|
||||
baseUrl: String,
|
||||
redirectUrl: String,
|
||||
canReName: Boolean,
|
||||
) {
|
||||
val infoRule = bookSource.getBookInfoRule()
|
||||
infoRule.init?.let {
|
||||
if (it.isNotBlank()) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "≡执行详情页初始化规则")
|
||||
analyzeRule.setContent(analyzeRule.getElement(it))
|
||||
}
|
||||
}
|
||||
val mCanReName = canReName && !infoRule.canReName.isNullOrBlank()
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取书名")
|
||||
BookHelp.formatBookName(analyzeRule.getString(infoRule.name)).let {
|
||||
if (it.isNotEmpty() && (mCanReName || book.name.isEmpty())) {
|
||||
book.name = it
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取作者")
|
||||
BookHelp.formatBookAuthor(analyzeRule.getString(infoRule.author)).let {
|
||||
if (it.isNotEmpty() && (mCanReName || book.author.isEmpty())) {
|
||||
book.author = it
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取分类")
|
||||
try {
|
||||
analyzeRule.getStringList(infoRule.kind)
|
||||
?.joinToString(",")
|
||||
?.let {
|
||||
if (it.isNotEmpty()) book.kind = it
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
} ?: Debug.log(bookSource.bookSourceUrl, "└")
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}")
|
||||
DebugLog.e("获取分类出错", e)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取字数")
|
||||
try {
|
||||
wordCountFormat(analyzeRule.getString(infoRule.wordCount)).let {
|
||||
if (it.isNotEmpty()) book.wordCount = it
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}")
|
||||
DebugLog.e("获取字数出错", e)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节")
|
||||
try {
|
||||
analyzeRule.getString(infoRule.lastChapter).let {
|
||||
if (it.isNotEmpty()) book.latestChapterTitle = it
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}")
|
||||
DebugLog.e("获取最新章节出错", e)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取简介")
|
||||
try {
|
||||
HtmlFormatter.format(analyzeRule.getString(infoRule.intro)).let {
|
||||
if (it.isNotEmpty()) book.intro = it
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}")
|
||||
DebugLog.e("获取简介出错", e)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接")
|
||||
try {
|
||||
analyzeRule.getString(infoRule.coverUrl).let {
|
||||
if (it.isNotEmpty()) {
|
||||
book.coverUrl =
|
||||
NetworkUtils.getAbsoluteURL(redirectUrl, it)
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${it}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}")
|
||||
DebugLog.e("获取封面出错", e)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
if (!book.isWebFile) {
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取目录链接")
|
||||
book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true)
|
||||
if (book.tocUrl.isEmpty()) book.tocUrl = baseUrl
|
||||
if (book.tocUrl == baseUrl) {
|
||||
book.tocHtml = body
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${book.tocUrl}")
|
||||
} else {
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取文件下载链接")
|
||||
book.downloadUrls = analyzeRule.getStringList(infoRule.downloadUrls, isUrl = true)
|
||||
if (book.downloadUrls.isNullOrEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "└")
|
||||
throw NoStackTraceException("下载链接为空")
|
||||
} else {
|
||||
Debug.log(
|
||||
bookSource.bookSourceUrl,
|
||||
"└" + TextUtils.join(",\n", book.downloadUrls!!)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.SearchBook
|
||||
import io.legato.kazusa.data.entities.rule.BookListRule
|
||||
import io.legato.kazusa.data.entities.rule.ExploreKind
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.source.exploreKindsJson
|
||||
import io.legato.kazusa.help.source.getBookType
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setRuleData
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.model.analyzeRule.RuleData
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.GSONStrict
|
||||
import io.legato.kazusa.utils.HtmlFormatter
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.StringUtils.wordCountFormat
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import splitties.init.appCtx
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* 获取书籍列表
|
||||
*/
|
||||
object BookList {
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun analyzeBookList(
|
||||
bookSource: BookSource,
|
||||
ruleData: RuleData,
|
||||
analyzeUrl: AnalyzeUrl,
|
||||
baseUrl: String,
|
||||
body: String?,
|
||||
isSearch: Boolean = true,
|
||||
isRedirect: Boolean = false,
|
||||
filter: ((name: String, author: String) -> Boolean)? = null,
|
||||
shouldBreak: ((size: Int) -> Boolean)? = null
|
||||
): ArrayList<SearchBook> {
|
||||
body ?: throw NoStackTraceException(
|
||||
appCtx.getString(
|
||||
R.string.error_get_web_content,
|
||||
analyzeUrl.ruleUrl
|
||||
)
|
||||
)
|
||||
val bookList = ArrayList<SearchBook>()
|
||||
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}")
|
||||
Debug.log(bookSource.bookSourceUrl, body, state = 10)
|
||||
val analyzeRule = AnalyzeRule(ruleData, bookSource)
|
||||
analyzeRule.setContent(body).setBaseUrl(baseUrl)
|
||||
analyzeRule.setRedirectUrl(baseUrl)
|
||||
analyzeRule.setCoroutineContext(coroutineContext)
|
||||
if (!isSearch) {
|
||||
checkExploreJson(bookSource)
|
||||
}
|
||||
if (isSearch) bookSource.bookUrlPattern?.let {
|
||||
coroutineContext.ensureActive()
|
||||
if (baseUrl.matches(it.toRegex())) {
|
||||
Debug.log(bookSource.bookSourceUrl, "≡链接为详情页")
|
||||
getInfoItem(
|
||||
bookSource,
|
||||
analyzeRule,
|
||||
analyzeUrl,
|
||||
body,
|
||||
baseUrl,
|
||||
ruleData.getVariable(),
|
||||
isRedirect,
|
||||
filter
|
||||
)?.let { searchBook ->
|
||||
searchBook.infoHtml = body
|
||||
bookList.add(searchBook)
|
||||
}
|
||||
return bookList
|
||||
}
|
||||
}
|
||||
val collections: List<Any>
|
||||
var reverse = false
|
||||
val bookListRule: BookListRule = when {
|
||||
isSearch -> bookSource.getSearchRule()
|
||||
bookSource.getExploreRule().bookList.isNullOrBlank() -> bookSource.getSearchRule()
|
||||
else -> bookSource.getExploreRule()
|
||||
}
|
||||
var ruleList: String = bookListRule.bookList ?: ""
|
||||
if (ruleList.startsWith("-")) {
|
||||
reverse = true
|
||||
ruleList = ruleList.substring(1)
|
||||
}
|
||||
if (ruleList.startsWith("+")) {
|
||||
ruleList = ruleList.substring(1)
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取书籍列表")
|
||||
collections = analyzeRule.getElements(ruleList)
|
||||
coroutineContext.ensureActive()
|
||||
if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "└列表为空,按详情页解析")
|
||||
getInfoItem(
|
||||
bookSource, analyzeRule, analyzeUrl, body, baseUrl, ruleData.getVariable(),
|
||||
isRedirect, filter
|
||||
)?.let { searchBook ->
|
||||
searchBook.infoHtml = body
|
||||
bookList.add(searchBook)
|
||||
}
|
||||
} else {
|
||||
val ruleName = analyzeRule.splitSourceRule(bookListRule.name)
|
||||
val ruleBookUrl = analyzeRule.splitSourceRule(bookListRule.bookUrl)
|
||||
val ruleAuthor = analyzeRule.splitSourceRule(bookListRule.author)
|
||||
val ruleCoverUrl = analyzeRule.splitSourceRule(bookListRule.coverUrl)
|
||||
val ruleIntro = analyzeRule.splitSourceRule(bookListRule.intro)
|
||||
val ruleKind = analyzeRule.splitSourceRule(bookListRule.kind)
|
||||
val ruleLastChapter = analyzeRule.splitSourceRule(bookListRule.lastChapter)
|
||||
val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount)
|
||||
Debug.log(bookSource.bookSourceUrl, "└列表大小:${collections.size}")
|
||||
for ((index, item) in collections.withIndex()) {
|
||||
getSearchItem(
|
||||
bookSource, analyzeRule, item, baseUrl, ruleData.getVariable(),
|
||||
index == 0,
|
||||
filter,
|
||||
ruleName = ruleName,
|
||||
ruleBookUrl = ruleBookUrl,
|
||||
ruleAuthor = ruleAuthor,
|
||||
ruleCoverUrl = ruleCoverUrl,
|
||||
ruleIntro = ruleIntro,
|
||||
ruleKind = ruleKind,
|
||||
ruleLastChapter = ruleLastChapter,
|
||||
ruleWordCount = ruleWordCount
|
||||
)?.let { searchBook ->
|
||||
if (baseUrl == searchBook.bookUrl) {
|
||||
searchBook.infoHtml = body
|
||||
}
|
||||
bookList.add(searchBook)
|
||||
}
|
||||
if (shouldBreak?.invoke(bookList.size) == true) {
|
||||
break
|
||||
}
|
||||
}
|
||||
val lh = LinkedHashSet(bookList)
|
||||
bookList.clear()
|
||||
bookList.addAll(lh)
|
||||
if (reverse) {
|
||||
bookList.reverse()
|
||||
}
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "◇书籍总数:${bookList.size}")
|
||||
return bookList
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private suspend fun getInfoItem(
|
||||
bookSource: BookSource,
|
||||
analyzeRule: AnalyzeRule,
|
||||
analyzeUrl: AnalyzeUrl,
|
||||
body: String,
|
||||
baseUrl: String,
|
||||
variable: String?,
|
||||
isRedirect: Boolean,
|
||||
filter: ((name: String, author: String) -> Boolean)?
|
||||
): SearchBook? {
|
||||
val book = Book(variable = variable)
|
||||
book.bookUrl = if (isRedirect) {
|
||||
baseUrl
|
||||
} else {
|
||||
NetworkUtils.getAbsoluteURL(analyzeUrl.url, analyzeUrl.ruleUrl)
|
||||
}
|
||||
book.origin = bookSource.bookSourceUrl
|
||||
book.originName = bookSource.bookSourceName
|
||||
book.originOrder = bookSource.customOrder
|
||||
book.type = bookSource.getBookType()
|
||||
analyzeRule.setRuleData(book)
|
||||
BookInfo.analyzeBookInfo(
|
||||
book,
|
||||
body,
|
||||
analyzeRule,
|
||||
bookSource,
|
||||
baseUrl,
|
||||
baseUrl,
|
||||
false
|
||||
)
|
||||
if (filter?.invoke(book.name, book.author) == false) {
|
||||
return null
|
||||
}
|
||||
if (book.name.isNotBlank()) {
|
||||
return book.toSearchBook()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private suspend fun getSearchItem(
|
||||
bookSource: BookSource,
|
||||
analyzeRule: AnalyzeRule,
|
||||
item: Any,
|
||||
baseUrl: String,
|
||||
variable: String?,
|
||||
log: Boolean,
|
||||
filter: ((name: String, author: String) -> Boolean)?,
|
||||
ruleName: List<AnalyzeRule.SourceRule>,
|
||||
ruleBookUrl: List<AnalyzeRule.SourceRule>,
|
||||
ruleAuthor: List<AnalyzeRule.SourceRule>,
|
||||
ruleKind: List<AnalyzeRule.SourceRule>,
|
||||
ruleCoverUrl: List<AnalyzeRule.SourceRule>,
|
||||
ruleWordCount: List<AnalyzeRule.SourceRule>,
|
||||
ruleIntro: List<AnalyzeRule.SourceRule>,
|
||||
ruleLastChapter: List<AnalyzeRule.SourceRule>
|
||||
): SearchBook? {
|
||||
val searchBook = SearchBook(variable = variable)
|
||||
searchBook.type = bookSource.getBookType()
|
||||
searchBook.origin = bookSource.bookSourceUrl
|
||||
searchBook.originName = bookSource.bookSourceName
|
||||
searchBook.originOrder = bookSource.customOrder
|
||||
analyzeRule.setRuleData(searchBook)
|
||||
analyzeRule.setContent(item)
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取书名", log)
|
||||
searchBook.name = BookHelp.formatBookName(analyzeRule.getString(ruleName))
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.name}", log)
|
||||
if (searchBook.name.isNotEmpty()) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
|
||||
searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor))
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log)
|
||||
if (filter?.invoke(searchBook.name, searchBook.author) == false) {
|
||||
return null
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
|
||||
try {
|
||||
searchBook.kind = analyzeRule.getStringList(ruleKind)?.joinToString(",")
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.kind ?: ""}", log)
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
|
||||
try {
|
||||
searchBook.wordCount = wordCountFormat(analyzeRule.getString(ruleWordCount))
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.wordCount}", log)
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节", log)
|
||||
try {
|
||||
searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter)
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.latestChapterTitle}", log)
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取简介", log)
|
||||
try {
|
||||
searchBook.intro = HtmlFormatter.format(analyzeRule.getString(ruleIntro))
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.intro}", log)
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接", log)
|
||||
try {
|
||||
analyzeRule.getString(ruleCoverUrl).let {
|
||||
if (it.isNotEmpty()) {
|
||||
searchBook.coverUrl = NetworkUtils.getAbsoluteURL(baseUrl, it)
|
||||
}
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.coverUrl ?: ""}", log)
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
Debug.log(bookSource.bookSourceUrl, "┌获取详情页链接", log)
|
||||
searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, isUrl = true)
|
||||
if (searchBook.bookUrl.isEmpty()) {
|
||||
searchBook.bookUrl = baseUrl
|
||||
}
|
||||
Debug.log(bookSource.bookSourceUrl, "└${searchBook.bookUrl}", log)
|
||||
return searchBook
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun checkExploreJson(bookSource: BookSource) {
|
||||
if (Debug.callback == null) {
|
||||
return
|
||||
}
|
||||
val json = bookSource.exploreKindsJson()
|
||||
if (json.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val kinds = GSONStrict.fromJsonArray<ExploreKind>(json).getOrNull()
|
||||
if (kinds != null) {
|
||||
return
|
||||
}
|
||||
GSON.fromJsonArray<ExploreKind>(json).getOrNull()?.let {
|
||||
Debug.log("≡发现地址规则 JSON 格式不规范,请改为规范格式")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.BookSourcePart
|
||||
import io.legato.kazusa.data.entities.SearchBook
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.ui.book.search.SearchScope
|
||||
import io.legato.kazusa.utils.getPrefBoolean
|
||||
import io.legato.kazusa.utils.mapParallelSafe
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExecutorCoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import splitties.init.appCtx
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.min
|
||||
|
||||
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
|
||||
val threadCount = AppConfig.threadCount
|
||||
private var searchPool: ExecutorCoroutineDispatcher? = null
|
||||
private var mSearchId = 0L
|
||||
private var searchPage = 1
|
||||
private var searchKey: String = ""
|
||||
private var bookSourceParts = emptyList<BookSourcePart>()
|
||||
private var searchBooks = arrayListOf<SearchBook>()
|
||||
private var searchJob: Job? = null
|
||||
|
||||
|
||||
private fun initSearchPool() {
|
||||
searchPool?.close()
|
||||
searchPool = Executors
|
||||
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
fun search(searchId: Long, key: String) {
|
||||
if (searchId != mSearchId) {
|
||||
if (key.isEmpty()) {
|
||||
return
|
||||
}
|
||||
searchKey = key
|
||||
if (mSearchId != 0L) {
|
||||
close()
|
||||
}
|
||||
searchBooks.clear()
|
||||
bookSourceParts = callBack.getSearchScope().getBookSourceParts()
|
||||
if (bookSourceParts.isEmpty()) {
|
||||
callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
|
||||
return
|
||||
}
|
||||
mSearchId = searchId
|
||||
searchPage = 1
|
||||
initSearchPool()
|
||||
} else {
|
||||
searchPage++
|
||||
}
|
||||
startSearch()
|
||||
}
|
||||
|
||||
private fun startSearch() {
|
||||
val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch)
|
||||
var hasMore = false
|
||||
searchJob = scope.launch(searchPool!!) {
|
||||
flow {
|
||||
for (bs in bookSourceParts) {
|
||||
bs.getBookSource()?.let {
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
callBack.onSearchStart()
|
||||
}.mapParallelSafe(threadCount) {
|
||||
withTimeout(30000L) {
|
||||
WebBook.searchBookAwait(
|
||||
it, searchKey, searchPage,
|
||||
filter = { name, author ->
|
||||
!precision || name.contains(searchKey) ||
|
||||
author.contains(searchKey)
|
||||
})
|
||||
}
|
||||
}.onEach { items ->
|
||||
for (book in items) {
|
||||
book.releaseHtmlData()
|
||||
}
|
||||
hasMore = hasMore || items.isNotEmpty()
|
||||
appDb.searchBookDao.insert(*items.toTypedArray())
|
||||
mergeItems(items, precision)
|
||||
currentCoroutineContext().ensureActive()
|
||||
callBack.onSearchSuccess(searchBooks)
|
||||
}.onCompletion {
|
||||
if (it == null) callBack.onSearchFinish(searchBooks.isEmpty(), hasMore)
|
||||
}.catch {
|
||||
AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean) {
|
||||
if (newDataS.isNotEmpty()) {
|
||||
val copyData = ArrayList(searchBooks)
|
||||
val equalData = arrayListOf<SearchBook>()
|
||||
val containsData = arrayListOf<SearchBook>()
|
||||
val otherData = arrayListOf<SearchBook>()
|
||||
copyData.forEach {
|
||||
coroutineContext.ensureActive()
|
||||
if (it.name == searchKey || it.author == searchKey) {
|
||||
equalData.add(it)
|
||||
} else if (it.name.contains(searchKey) || it.author.contains(searchKey)) {
|
||||
containsData.add(it)
|
||||
} else {
|
||||
otherData.add(it)
|
||||
}
|
||||
}
|
||||
newDataS.forEach { nBook ->
|
||||
coroutineContext.ensureActive()
|
||||
if (nBook.name == searchKey || nBook.author == searchKey) {
|
||||
var hasSame = false
|
||||
equalData.forEach { pBook ->
|
||||
coroutineContext.ensureActive()
|
||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
||||
pBook.addOrigin(nBook.origin)
|
||||
hasSame = true
|
||||
}
|
||||
}
|
||||
if (!hasSame) {
|
||||
equalData.add(nBook)
|
||||
}
|
||||
} else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) {
|
||||
var hasSame = false
|
||||
containsData.forEach { pBook ->
|
||||
coroutineContext.ensureActive()
|
||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
||||
pBook.addOrigin(nBook.origin)
|
||||
hasSame = true
|
||||
}
|
||||
}
|
||||
if (!hasSame) {
|
||||
containsData.add(nBook)
|
||||
}
|
||||
} else if (!precision) {
|
||||
var hasSame = false
|
||||
otherData.forEach { pBook ->
|
||||
coroutineContext.ensureActive()
|
||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
||||
pBook.addOrigin(nBook.origin)
|
||||
hasSame = true
|
||||
}
|
||||
}
|
||||
if (!hasSame) {
|
||||
otherData.add(nBook)
|
||||
}
|
||||
}
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
equalData.sortByDescending { it.origins.size }
|
||||
equalData.addAll(containsData.sortedByDescending { it.origins.size })
|
||||
if (!precision) {
|
||||
equalData.addAll(otherData)
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
searchBooks = equalData
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSearch() {
|
||||
close()
|
||||
callBack.onSearchCancel()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
searchJob?.cancel()
|
||||
searchPool?.close()
|
||||
searchPool = null
|
||||
mSearchId = 0L
|
||||
}
|
||||
|
||||
interface CallBack {
|
||||
fun getSearchScope(): SearchScope
|
||||
fun onSearchStart()
|
||||
fun onSearchSuccess(searchBooks: List<SearchBook>)
|
||||
fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean)
|
||||
fun onSearchCancel(exception: Throwable? = null)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package io.legato.kazusa.model.webBook
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.BookSourcePart
|
||||
import io.legato.kazusa.data.entities.SearchBook
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.book.addType
|
||||
import io.legato.kazusa.help.book.removeAllBookType
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.http.StrResponse
|
||||
import io.legato.kazusa.help.source.getBookType
|
||||
import io.legato.kazusa.model.Debug
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.model.analyzeRule.RuleData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
object WebBook {
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
fun searchBook(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
key: String,
|
||||
page: Int? = 1,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
executeContext: CoroutineContext = Dispatchers.Main,
|
||||
): Coroutine<ArrayList<SearchBook>> {
|
||||
return Coroutine.async(scope, context, start = start, executeContext = executeContext) {
|
||||
searchBookAwait(bookSource, key, page)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchBookAwait(
|
||||
bookSource: BookSource,
|
||||
key: String,
|
||||
page: Int? = 1,
|
||||
filter: ((name: String, author: String) -> Boolean)? = null,
|
||||
shouldBreak: ((size: Int) -> Boolean)? = null
|
||||
): ArrayList<SearchBook> {
|
||||
val searchUrl = bookSource.searchUrl
|
||||
if (searchUrl.isNullOrBlank()) {
|
||||
throw NoStackTraceException("搜索url不能为空")
|
||||
}
|
||||
val ruleData = RuleData()
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = searchUrl,
|
||||
key = key,
|
||||
page = page,
|
||||
baseUrl = bookSource.bookSourceUrl,
|
||||
source = bookSource,
|
||||
ruleData = ruleData,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
var res = analyzeUrl.getStrResponseAwait()
|
||||
//检测书源是否已登录
|
||||
bookSource.loginCheckJs?.let { checkJs ->
|
||||
if (checkJs.isNotBlank()) {
|
||||
res = analyzeUrl.evalJS(checkJs, res) as StrResponse
|
||||
}
|
||||
}
|
||||
checkRedirect(bookSource, res)
|
||||
return BookList.analyzeBookList(
|
||||
bookSource = bookSource,
|
||||
ruleData = ruleData,
|
||||
analyzeUrl = analyzeUrl,
|
||||
baseUrl = res.url,
|
||||
body = res.body,
|
||||
isSearch = true,
|
||||
isRedirect = res.raw.priorResponse?.isRedirect == true,
|
||||
filter = filter,
|
||||
shouldBreak = shouldBreak
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现
|
||||
*/
|
||||
fun exploreBook(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
url: String,
|
||||
page: Int? = 1,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
): Coroutine<List<SearchBook>> {
|
||||
return Coroutine.async(scope, context) {
|
||||
exploreBookAwait(bookSource, url, page)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exploreBookAwait(
|
||||
bookSource: BookSource,
|
||||
url: String,
|
||||
page: Int? = 1,
|
||||
): ArrayList<SearchBook> {
|
||||
val ruleData = RuleData()
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = url,
|
||||
page = page,
|
||||
baseUrl = bookSource.bookSourceUrl,
|
||||
source = bookSource,
|
||||
ruleData = ruleData,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
var res = analyzeUrl.getStrResponseAwait()
|
||||
//检测书源是否已登录
|
||||
bookSource.loginCheckJs?.let { checkJs ->
|
||||
if (checkJs.isNotBlank()) {
|
||||
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
|
||||
}
|
||||
}
|
||||
checkRedirect(bookSource, res)
|
||||
return BookList.analyzeBookList(
|
||||
bookSource = bookSource,
|
||||
ruleData = ruleData,
|
||||
analyzeUrl = analyzeUrl,
|
||||
baseUrl = res.url,
|
||||
body = res.body,
|
||||
isSearch = false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 书籍信息
|
||||
*/
|
||||
fun getBookInfo(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
canReName: Boolean = true,
|
||||
): Coroutine<Book> {
|
||||
return Coroutine.async(scope, context) {
|
||||
getBookInfoAwait(bookSource, book, canReName)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBookInfoAwait(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
canReName: Boolean = true,
|
||||
): Book {
|
||||
book.removeAllBookType()
|
||||
book.addType(bookSource.getBookType())
|
||||
if (!book.infoHtml.isNullOrEmpty()) {
|
||||
BookInfo.analyzeBookInfo(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
baseUrl = book.bookUrl,
|
||||
redirectUrl = book.bookUrl,
|
||||
body = book.infoHtml,
|
||||
canReName = canReName
|
||||
)
|
||||
} else {
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = book.bookUrl,
|
||||
baseUrl = bookSource.bookSourceUrl,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
var res = analyzeUrl.getStrResponseAwait()
|
||||
//检测书源是否已登录
|
||||
bookSource.loginCheckJs?.let { checkJs ->
|
||||
if (checkJs.isNotBlank()) {
|
||||
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
|
||||
}
|
||||
}
|
||||
checkRedirect(bookSource, res)
|
||||
BookInfo.analyzeBookInfo(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
baseUrl = book.bookUrl,
|
||||
redirectUrl = res.url,
|
||||
body = res.body,
|
||||
canReName = canReName
|
||||
)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录
|
||||
*/
|
||||
fun getChapterList(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
runPerJs: Boolean = false,
|
||||
context: CoroutineContext = Dispatchers.IO
|
||||
): Coroutine<List<BookChapter>> {
|
||||
return Coroutine.async(scope, context) {
|
||||
getChapterListAwait(bookSource, book, runPerJs).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runPreUpdateJs(bookSource: BookSource, book: Book): Result<Unit> {
|
||||
return kotlin.runCatching {
|
||||
val preUpdateJs = bookSource.ruleToc?.preUpdateJs
|
||||
if (!preUpdateJs.isNullOrBlank()) {
|
||||
AnalyzeRule(book, bookSource, true)
|
||||
.setCoroutineContext(coroutineContext)
|
||||
.evalJS(preUpdateJs)
|
||||
}
|
||||
}.onFailure {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("执行preUpdateJs规则失败 书源:${bookSource.bookSourceName}", it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChapterListAwait(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
runPerJs: Boolean = false
|
||||
): Result<List<BookChapter>> {
|
||||
book.removeAllBookType()
|
||||
book.addType(bookSource.getBookType())
|
||||
return kotlin.runCatching {
|
||||
if (runPerJs) {
|
||||
runPreUpdateJs(bookSource, book).getOrThrow()
|
||||
}
|
||||
if (book.bookUrl == book.tocUrl && !book.tocHtml.isNullOrEmpty()) {
|
||||
BookChapterList.analyzeChapterList(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
baseUrl = book.tocUrl,
|
||||
redirectUrl = book.tocUrl,
|
||||
body = book.tocHtml
|
||||
)
|
||||
} else {
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = book.tocUrl,
|
||||
baseUrl = book.bookUrl,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
var res = analyzeUrl.getStrResponseAwait()
|
||||
//检测书源是否已登录
|
||||
bookSource.loginCheckJs?.let { checkJs ->
|
||||
if (checkJs.isNotBlank()) {
|
||||
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
|
||||
}
|
||||
}
|
||||
checkRedirect(bookSource, res)
|
||||
BookChapterList.analyzeChapterList(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
baseUrl = book.tocUrl,
|
||||
redirectUrl = res.url,
|
||||
body = res.body
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
coroutineContext.ensureActive()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节内容
|
||||
*/
|
||||
fun getContent(
|
||||
scope: CoroutineScope,
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
nextChapterUrl: String? = null,
|
||||
needSave: Boolean = true,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
executeContext: CoroutineContext = Dispatchers.Main,
|
||||
semaphore: Semaphore? = null,
|
||||
): Coroutine<String> {
|
||||
return Coroutine.async(
|
||||
scope,
|
||||
context,
|
||||
start = start,
|
||||
executeContext = executeContext,
|
||||
semaphore = semaphore
|
||||
) {
|
||||
getContentAwait(bookSource, book, bookChapter, nextChapterUrl, needSave)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContentAwait(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
nextChapterUrl: String? = null,
|
||||
needSave: Boolean = true
|
||||
): String {
|
||||
if (bookSource.getContentRule().content.isNullOrEmpty()) {
|
||||
Debug.log(bookSource.bookSourceUrl, "⇒正文规则为空,使用章节链接:${bookChapter.url}")
|
||||
return bookChapter.url
|
||||
}
|
||||
if (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) {
|
||||
Debug.log(bookSource.bookSourceUrl, "⇒一级目录正文不解析规则")
|
||||
return bookChapter.tag ?: ""
|
||||
}
|
||||
return if (bookChapter.url == book.bookUrl && !book.tocHtml.isNullOrEmpty()) {
|
||||
BookContent.analyzeContent(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
bookChapter = bookChapter,
|
||||
baseUrl = bookChapter.getAbsoluteURL(),
|
||||
redirectUrl = bookChapter.getAbsoluteURL(),
|
||||
body = book.tocHtml,
|
||||
nextChapterUrl = nextChapterUrl,
|
||||
needSave = needSave
|
||||
)
|
||||
} else {
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
mUrl = bookChapter.getAbsoluteURL(),
|
||||
baseUrl = book.tocUrl,
|
||||
source = bookSource,
|
||||
ruleData = book,
|
||||
chapter = bookChapter,
|
||||
coroutineContext = coroutineContext
|
||||
)
|
||||
var res = analyzeUrl.getStrResponseAwait(
|
||||
jsStr = bookSource.getContentRule().webJs,
|
||||
sourceRegex = bookSource.getContentRule().sourceRegex
|
||||
)
|
||||
//检测书源是否已登录
|
||||
bookSource.loginCheckJs?.let { checkJs ->
|
||||
if (checkJs.isNotBlank()) {
|
||||
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
|
||||
}
|
||||
}
|
||||
checkRedirect(bookSource, res)
|
||||
BookContent.analyzeContent(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
bookChapter = bookChapter,
|
||||
baseUrl = bookChapter.getAbsoluteURL(),
|
||||
redirectUrl = res.url,
|
||||
body = res.body,
|
||||
nextChapterUrl = nextChapterUrl,
|
||||
needSave = needSave
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 精准搜索
|
||||
*/
|
||||
fun preciseSearch(
|
||||
scope: CoroutineScope,
|
||||
bookSourceParts: List<BookSourcePart>,
|
||||
name: String,
|
||||
author: String,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
semaphore: Semaphore? = null,
|
||||
): Coroutine<Pair<Book, BookSource>> {
|
||||
return Coroutine.async(scope, context, semaphore = semaphore) {
|
||||
for (s in bookSourceParts) {
|
||||
val source = s.getBookSource() ?: continue
|
||||
val book = preciseSearchAwait(source, name, author).getOrNull()
|
||||
if (book != null) {
|
||||
return@async Pair(book, source)
|
||||
}
|
||||
}
|
||||
throw NoStackTraceException("没有搜索到<$name>$author")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun preciseSearchAwait(
|
||||
bookSource: BookSource,
|
||||
name: String,
|
||||
author: String,
|
||||
): Result<Book> {
|
||||
return kotlin.runCatching {
|
||||
coroutineContext.ensureActive()
|
||||
searchBookAwait(
|
||||
bookSource, name,
|
||||
filter = { fName, fAuthor -> fName == name && fAuthor == author },
|
||||
shouldBreak = { it > 0 }
|
||||
).firstOrNull()?.let { searchBook ->
|
||||
coroutineContext.ensureActive()
|
||||
return@runCatching searchBook.toBook()
|
||||
}
|
||||
throw NoStackTraceException("未搜索到 $name($author) 书籍")
|
||||
}.onFailure {
|
||||
coroutineContext.ensureActive()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重定向
|
||||
*/
|
||||
private fun checkRedirect(bookSource: BookSource, response: StrResponse) {
|
||||
response.raw.priorResponse?.let {
|
||||
if (it.isRedirect) {
|
||||
Debug.log(bookSource.bookSourceUrl, "≡检测到重定向(${it.code})")
|
||||
Debug.log(bookSource.bookSourceUrl, "┌重定向后地址")
|
||||
Debug.log(bookSource.bookSourceUrl, "└${response.url}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user