Legado Runtime 抽取方案
目的
本文档定义一条可执行的方案:将 ../legado-with-MD3 中真正有复用价值的规则运行时能力抽成独立模块,并以宿主适配接口的形式接入 Kototoro。
目标不是把 Legado 现有 APK、AAR 或 app 模块整体嵌入 Kototoro。
目标是:
- 复用 Legado 规则语义与运行时行为
- 避免把 Legado 的应用层、存储层、UI 层一并带入
- 降低 Kototoro 继续手工对齐 Legado 行为的维护成本
- 为后续兼容测试和行为回归建立稳定边界
非目标
以下内容不在本方案范围内:
- 直接把
../legado-with-MD3/app打成产物并嵌入 Kototoro - 复用 Legado 的 Compose UI、Activity、Service、Receiver
- 复用 Legado 的 Room 实体、数据库和 DAO
- 复用 Legado 的 Firebase、Cronet、Koin、Media3、Glide、Startup 等应用级依赖
- 一次性替换 Kototoro 当前全部 Legado 实现
现状判断
1. Legado 当前“规则核心”并不是独立模块
Legado 的规则主实现位于:
这些代码目前仍然放在 app 模块,而不是独立 library 模块。
2. 规则运行时与宿主实现存在重耦合
当前耦合点包括:
AnalyzeRule直接依赖BaseSource、Book、BookChapter、BookSource、RssArticleAnalyzeUrl直接依赖 HTTP、Cookie、缓存、WebView、媒体 URL 处理BaseSource本身带有登录、变量、Cookie、加密、JS 扩展行为JsExtensions直接依赖 Android API、WebView、应用配置、UI 跳转CookieStore直接落库到 Legado 自己的appDb
相关代码:
3. Kototoro 当前路线已经是“源码级行为对齐”
Kototoro 已经在维护自己的 Legado 兼容实现,例如:
因此更合理的方向不是“整包引入”,而是为现有适配实现找到一个长期可维护的共享内核。
设计原则
KISS
只抽“规则内核 + 宿主接口 + Android 默认桥接”三层。
不要一开始就引入更多模块、更多平台适配、更多抽象。
YAGNI
只为 Kototoro 当前真实需要的 Legado 能力抽取公共运行时:
- 规则切分
- JS/CSS/XPath/JSONPath 解析
- URL 模板解析
- 变量读写
- HTTP 请求计划生成
- JS 运行时与宿主桥接
不为暂时不用的 RSS、字体处理、外部 UI、完整应用配置体系预留过度接口。
DRY
避免继续在 Kototoro 和 Legado 之间长期维护两套不断漂移的规则语义实现。
SOLID
- 单一职责:规则解释、HTTP 执行、变量存储、Cookie 管理分别拆开
- 开闭原则:新增宿主只需实现接口,不改规则核心
- 依赖倒置:规则核心依赖抽象接口,不依赖 Kototoro 或 Legado 的具体实现
目标结构
建议在 Legado 侧或独立仓库中抽出以下模块。
:modules:legado-rule-core
职责:
- 规则语法模型
- 规则分发与解释
- 字符串替换、占位符处理
- JS/CSS/XPath/JSONPath 基础解析
- 请求计划模型
要求:
- 尽量纯 Kotlin
- 最多依赖必要的解析库和脚本引擎抽象
- 不依赖 Android
Context - 不依赖 Room、UI、应用配置、数据库
建议放入的内容:
RuleAnalyzerRuleDataRuleDataInterfaceAnalyzeByJSoupAnalyzeByJSonPathAnalyzeByXPathAnalyzeByRegexAnalyzeRule的核心逻辑- 从
AnalyzeUrl中拆出的 URL 模板与请求计划逻辑
:modules:legado-rule-bridge
职责:
- 定义宿主能力接口
- 定义运行时上下文对象
- 定义日志、缓存、变量、Cookie、HTTP、WebView、JS 绑定接口
要求:
- 不依赖具体宿主实现
- 不绑定 Kototoro 或 Legado 的实体类型
:modules:legado-rule-android
职责:
- 提供 Android 默认实现
- 对接
SharedPreferences、OkHttp、可选WebView - 提供默认的
CookieStorePort、VariableStorePort、LoggerPort
要求:
- 不直接依赖 Legado
appDb - 不直接依赖 Legado UI Activity
- 可以依赖 Android 和 OkHttp
不建议复用的现有类型
以下类型不应该原样进入抽出的 runtime:
io.legado.app.data.entities.BookSourceio.legado.app.data.entities.Bookio.legado.app.data.entities.BookChapterio.legado.app.data.entities.BaseSourceio.legado.app.help.JsExtensionsio.legado.app.help.http.CookieStore
原因:
- 混合了实体、持久化、宿主行为、Android 能力
- 会把运行时边界重新污染回应用层
建议的新抽象
1. Source 描述对象
用纯 DTO 替代 BookSource / BaseSource 的运行时输入。
data class LegadoSourceDescriptor(
val key: String,
val name: String,
val baseUrl: String?,
val headerRule: String?,
val loginUrlRule: String?,
val loginUiRule: String?,
val jsLib: String?,
val enabledCookieJar: Boolean,
val concurrentRate: String?,
val exploreUrl: String?,
val searchUrl: String?,
val bookInfoRule: LegadoBookInfoRule?,
val tocRule: LegadoTocRule?,
val contentRule: LegadoContentRule?,
)这个对象只承担规则运行时所需数据,不带 Room、Parcelable、数据库注解。
2. Rule 数据上下文
替代 Book、BookChapter、RssArticle 直接入栈的做法。
data class LegadoRuleContext(
val source: LegadoSourceDescriptor?,
val book: Map<String, Any?> = emptyMap(),
val chapter: Map<String, Any?> = emptyMap(),
val item: Any? = null,
val extras: Map<String, Any?> = emptyMap(),
)这样可以把运行时依赖从具体实体改为稳定的数据视图。
3. 宿主能力接口
HTTP
interface LegadoHttpExecutor {
suspend fun execute(plan: LegadoRequestPlan): LegadoHttpResponse
}Cookie
interface LegadoCookieStorePort {
fun getCookie(urlOrKey: String): String?
fun setCookie(urlOrKey: String, cookie: String?)
fun replaceCookie(urlOrKey: String, cookie: String)
fun removeCookie(urlOrKey: String)
}变量与登录信息
interface LegadoVariableStorePort {
fun getSourceVariable(sourceKey: String): String?
fun putSourceVariable(sourceKey: String, value: String?)
fun getLoginInfo(sourceKey: String): String?
fun putLoginInfo(sourceKey: String, value: String?)
fun getLoginHeader(sourceKey: String): String?
fun putLoginHeader(sourceKey: String, value: String?)
}WebView 抓取
interface LegadoWebViewFetcher {
suspend fun fetch(request: LegadoWebViewRequest): LegadoWebViewResult
}日志
interface LegadoRuntimeLogger {
fun debug(tag: String, message: String)
fun warn(tag: String, message: String, throwable: Throwable? = null)
}4. JS 运行时桥接
JsExtensions 不应原样暴露。
建议拆成显式 bridge:
interface LegadoJsHostApi {
suspend fun ajax(url: String, options: LegadoAjaxOptions? = null): String?
suspend fun connect(url: String, headersJson: String? = null, callTimeout: Long? = null): LegadoHttpResponse
suspend fun webView(request: LegadoWebViewRequest): String?
fun getCookie(tag: String): String?
fun putVariable(key: String, value: String?)
fun getVariable(key: String): String?
}JS 中需要哪些方法,由 bridge 显式注册,而不是把整个应用辅助能力全部挂进脚本上下文。
当前推进状态
截至当前版本,Kototoro 侧已经完成第一阶段的“运行时边界收口”:
AnalyzeUrl已拆出最小 JS 求值与请求计划构建AnalyzeRule已切到LegadoRuleRuntimeContext- 模板替换、规则分类、segment 切分等纯逻辑已经从
AnalyzeRule本体中抽离 BookList、BookInfo、BookChapterList、BookContent的主解析路径已改为显式注入 runtime context
这说明方案已经从“架构上可行”推进到“代码组织上初步落地”。
但这并不等于可以立即删除 LegadoSandbox。当前真正阻塞 runtime core 独立运行的,已经不是规则拆分本身,而是剩余的宿主状态与副作用能力。
剩余 Sandbox 强依赖清单
A. JS 执行上下文写入
这些能力目前仍然依赖 LegadoSandbox 直接操作 JS 上下文:
setResult(result)eval(script)execute(script)putVariable(key, value)/getVariable(key)/getVariableAny(key)
当前使用位置包括:
AnalyzeRule通过LegadoSandboxRuleRuntimeContext间接依赖这些能力AnalyzeUrl通过LegadoSandboxJsEvaluator依赖key/page/baseUrl/result注入后再执行 JSBookChapterList的formatJsBookContent的callBackJsLegadoRepository.runUserScript()/evalUserExpression()
结论:
- 规则核心并不真正需要整个
LegadoSandbox - 但它仍然需要一个“可写入变量、设置 result、执行 JS 并读回变量”的最小 JS runtime port
B. 书籍 / 章节上下文注入
这些能力目前仍由 LegadoSandbox 持有并同步到 JS context:
setBook(BookContext)setChapter(ChapterContext)
当前使用位置包括:
BookInfo.parse()在详情解析前后更新书籍上下文LegadoRepository.getPagesFlow()在正文解析前设置章节上下文- 若规则脚本通过
book.xxx/chapter.xxx/book.getVariable(...)取值,这部分仍然必须保留
结论:
- 后续需要把
book/chapter从LegadoSandbox的隐式上下文,提升为显式 runtime context 输入 - 在此之前,详情、目录、正文链路都还不能完全脱离 sandbox
C. 持久变量与登录状态
当前 LegadoSandbox 仍承担变量持久化和恢复:
- source variable
- login info
- 临时变量回填到 JS context
虽然底层已经开始走:
KototoroLegadoVariableStoreKototoroLegadoCookieStore
但上层规则执行仍然默认通过 LegadoSandbox 访问这些状态。
结论:
- 变量和 cookie 的“存储端口”已具备
- 还缺一个不依赖 sandbox 的“运行时状态装配器”
D. 规则后的副作用脚本
以下脚本不是纯规则求值,而是带宿主副作用的流程脚本:
formatJscallBackJs- 登录相关脚本
- 设置页用户手动触发脚本
这些脚本能不能迁到独立 runtime,不取决于 AnalyzeRule 是否继续拆,而取决于:
- runtime 是否能暴露与当前 JS context 足够接近的 API
- 是否允许继续复用 Kototoro 的
JavaScriptEngine
结论:
- 这是第二阶段问题
- 不应和第一阶段“规则内核抽离”混在一起一起做
最小 Runtime 试跑链路
下一步最有价值的工作,不是继续拆 AnalyzeRule,而是做一条最小可运行链路,验证 runtime core 能否脱离 LegadoSandbox 跑通。
建议只覆盖以下四步:
- 输入
LegadoSourceDescriptor - 构建
LegadoRequestPlan - 执行一次 HTTP 请求
- 用
AnalyzeRule返回字符串或列表结果
试跑范围
建议只选“列表页解析”做第一条链:
- 不含
setBook - 不含
setChapter - 不含
callBackJs - 不含
formatJs - 不含正文翻页与图片解码
原因:
- 这是当前最短闭环
- 既能覆盖 URL 构建,也能覆盖请求和规则求值
- 成功后可以证明“最小 runtime core + bridge”已具备真实执行能力
试跑输入
最小输入应包含:
- source 基本信息
searchUrl或exploreUrl- header 规则
- key / page / baseUrl
- variable store / cookie store / http executor / js evaluator
试跑输出
最小输出只要求:
- 最终请求 URL
- 请求方法 / header / body
- 原始响应文本
- 解析出的
List<String>或List<Any>
不要求:
- 直接产出完整
Content - 直接替换现有
LegadoRepository - 覆盖登录、正文、目录分页
试跑成功标准
如果以下条件满足,就说明方案进入下一阶段:
AnalyzeUrl不依赖LegadoSandbox也能产出正确LegadoRequestPlanAnalyzeRule不依赖LegadoSandbox也能跑完列表规则- JS 规则中的
result、baseUrl、page、key能通过最小 port 正常注入 - 变量读写不需要通过
SharedPreferences直连 JS 环境
下一阶段建议
下一阶段不建议继续做大规模文件拆分,而是按下面顺序推进:
1. 建一个无 Sandbox 的最小 Rule Runtime Adapter
最少需要提供:
evalJs(script, result, baseUrl)putVariable(key, value)getVariable(key)getVariableAny(key)
并补充:
keypagebaseUrlresult
这些运行时注入能力。
2. 用一条列表链路做试跑
优先在 exploreUrl 或 searchUrl 上验证:
- URL 构建
- HTTP
- 规则解析
先不要碰:
- 正文
- 目录分页
- 登录
- callback / formatJs
3. 试跑通过后,再处理上下文型副作用
包括:
setBooksetChaptercallBackJsformatJs
这一步才是真正决定是否能完全替换 LegadoSandbox 的阶段。
结论
到当前阶段,继续机械拆分 AnalyzeRule 的收益已经不高。
真正有价值的下一步是:
- 明确剩余
sandbox强依赖 - 搭一条最小 runtime 试跑链
- 用运行结果判断哪些能力必须进入 bridge,哪些可以继续留在 Kototoro 宿主层
也就是说,方向已经从“继续拆代码”切换为“验证最小可运行 runtime”。
AnalyzeUrl 的拆分建议
AnalyzeUrl 当前职责过重,不应整体搬入 runtime。
建议拆成三个部分。
UrlTemplateEvaluator
职责:
- 处理
@js/<js> - 处理
{{...}} - 处理
{{page}} - 处理
<a,b,c>这类分页模板
输出:
- 已替换的规则字符串
RequestPlanBuilder
职责:
- 从规则字符串解析出 URL、本体、header、charset、method、body、retry、cookie、webView 等选项
输出:
LegadoRequestPlan
data class LegadoRequestPlan(
val url: String,
val method: HttpMethod,
val headers: Map<String, String>,
val body: String?,
val charset: String?,
val retryCount: Int,
val useWebView: Boolean,
val webJs: String?,
val bodyJs: String?,
val proxy: String?,
val readTimeoutMillis: Long?,
val callTimeoutMillis: Long?,
)RequestExecutorFacade
职责:
- 调用
LegadoHttpExecutor - 必要时走
LegadoWebViewFetcher - 组合 Cookie、重试、响应解码
这样一拆,核心解析逻辑与网络执行逻辑就能彻底解耦。
AnalyzeRule 的拆分建议
AnalyzeRule 当前既做规则解释,又做上下文读写,还做 JS bridge 入口。
建议保留一个核心解释器,再把环境相关能力下沉到 RuntimeContext。
data class LegadoRuntimeContext(
val source: LegadoSourceDescriptor?,
val ruleContext: LegadoRuleContext,
val httpExecutor: LegadoHttpExecutor,
val cookieStore: LegadoCookieStorePort,
val variableStore: LegadoVariableStorePort,
val webViewFetcher: LegadoWebViewFetcher?,
val logger: LegadoRuntimeLogger,
)核心解释器只依赖 LegadoRuntimeContext,不直接访问任何 App 单例。
Kototoro 侧映射建议
Kototoro 不需要等待 Legado 先完成抽取,完全可以先按未来模块边界整理自己的实现。
Kototoro 中已有可对应的能力
- HTTP:
LegadoRepository/ 现有网络层 - 变量存储:
SharedPreferences与当前 source auth 流程 - JS sandbox:
LegadoSandbox - 规则实体:
LegadoBookSource - 兼容测试基线:
docs/reference/legado-adaptation-gap-analysis.md
建议在 Kototoro 先形成的边界
core/parser/legado/runtime/
建议新增或整理:
LegadoRequestPlanLegadoHttpExecutorLegadoVariableStoreLegadoCookieStoreLegadoRuntimeContext
core/parser/legado/model/
收敛纯运行时模型:
- source descriptor
- rule context
- response model
core/parser/legado/bridge/
收敛宿主实现:
KototoroLegadoHttpExecutorKototoroLegadoVariableStoreKototoroLegadoCookieStoreKototoroLegadoWebViewFetcher
这样即使短期不真正拆仓,也能先把 Kototoro 内部结构对齐到未来可抽取的形态。
分阶段迁移顺序
Phase 1: 先把 Kototoro 自己的运行时边界整理出来
目标:
- 不改行为
- 先把 HTTP、变量、Cookie、WebView、日志抽成接口
任务:
- 从
LegadoRepository中抽出LegadoHttpExecutor - 从
LegadoSandbox/ source auth 中抽出LegadoVariableStore - 从现有 Cookie 读写路径抽出
LegadoCookieStore - 把
AnalyzeUrl的网络执行逻辑迁出
完成标准:
AnalyzeUrl不再直接发请求AnalyzeRule不再直接触碰宿主持久化
Phase 2: 让 Kototoro 的规则核心只依赖运行时接口
目标:
- 让
AnalyzeRule/AnalyzeUrl只依赖 runtime 抽象
任务:
- 引入
LegadoRuntimeContext - 清理
Book/Chapter/Source直接实体依赖 - 把规则执行上下文统一改为 DTO 或 Map 视图
完成标准:
- 核心解析逻辑可以脱离 Kototoro App 单例单测
Phase 3: 在 Legado 侧验证最小抽取集
目标:
- 从
../legado-with-MD3抽出一组最小 library 模块
任务:
- 先抽
RuleAnalyzer、RuleData、AnalyzeBy* - 再抽
AnalyzeRule核心部分 - 最后再考虑
AnalyzeUrl的模板和请求计划部分
完成标准:
- library 模块不依赖 Legado
appDb - library 模块不依赖 Legado UI
- library 模块可由 Kototoro 以实现接口的形式接入
Phase 4: 双侧行为对比与回归测试
目标:
- 避免抽取后行为漂移
任务:
- 基于 legado-adaptation-gap-analysis.md 整理规则样例
- 建立 golden tests
- 用同一组样例对比:
- Legado 原实现
- Kototoro 当前实现
- 抽出的共享 runtime
完成标准:
- 关键规则行为回归可自动发现
风险评估
风险 1:抽象过早,边界错误
表现:
- 一开始就为大量低频能力设计复杂接口
应对:
- 只覆盖 Kototoro 当前真实使用的规则与 API
风险 2:把 Legado 的应用级能力误认为运行时必需
表现:
- 把 Firebase、Room、UI、Cronet 一并带进 runtime
应对:
- 以“无 Android App 状态依赖”为硬约束做审查
风险 3:行为兼容性回退
表现:
- 抽取后规则语义和原始 Legado 再次漂移
应对:
- 先建 golden tests,再做重构
风险 4:Kototoro 现有在研改动冲突
表现:
- 直接改现有
core/parser/legado/*文件,干扰当前兼容工作
应对:
- 第一阶段优先文档化和接口化
- 以增量方式引入新 runtime 包,不在一轮里重写全部实现
建议的近期执行项
优先级从高到低:
- 在 Kototoro 中补一层
runtime抽象目录,只做接口和模型,不做大改 - 为当前
AnalyzeRule/AnalyzeUrl建 golden tests - 从
AnalyzeUrl中分离请求计划模型 - 从当前 source auth / variable 逻辑中分离
VariableStore - 再决定是否同步改造
../legado-with-MD3
结论
“把 Legado runtime 打包进 Kototoro”这件事本身是可行的,但成立的前提不是直接消费现有 app 产物,而是先把它重构成一个最小、纯粹、宿主无关的规则运行时。
短期最稳的路线不是整包嵌入,而是:
- 先在 Kototoro 里对齐未来 runtime 边界
- 再从 Legado 提炼共享内核
- 最后用回归测试保障行为兼容
这条路径实现成本更高,但技术债更少,也更符合 Kototoro 当前已经在走的源码级兼容路线。