This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
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("&lt;img", "&lt; 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}")
}
}
}
}