feat(reader): legado 书源规则引擎 Go 移植 + 阅读 P0 后端

- internal/service/reader/rule/:逐方法移植 legado analyzeRule 包
  (AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/
  AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer),JS 规则留 P2 接入点
- 书源导入(JSON数组/对象/Base64/URL)、多源聚合搜索(legado 四档
  排序合并)、详情/目录/正文(nextContentUrl 翻页合并)
- 数据模型 4 表注册迁移;/api/reader/* 路由组(书源/搜索/书架/进度/调试)
- 单测 + httptest 全链路端到端测试
- docs/reader-ui-spec.md:legado UI 交互仿制规格(供 P1 前端使用)
This commit is contained in:
truewhile
2026-09-30 15:41:58 +08:00
parent c84f2604ed
commit b8e6418326
28 changed files with 5736 additions and 14 deletions
+520
View File
@@ -0,0 +1,520 @@
package rule
import (
"net/url"
"strings"
"github.com/PaesslerAG/jsonpath"
"golang.org/x/net/html"
)
// 本文件对应 AnalyzeRule.kt 主类。
// AnalyzeRule 解析规则获取结果(对应 AnalyzeRule)。
type AnalyzeRule struct {
content any
baseUrl string
redirectURL *url.URL
isJSON bool
isRegex bool
// jsRunner 由 P2 阶段的 goja 引擎注入;nil 时 JS 规则报 ErrJsUnsupported。
jsRunner func(js string, result any) (any, error)
// 变量层级(对应 chapter → book → ruleData → source)
chapterVars map[string]string
bookVars map[string]string
vars map[string]string
chapterTitle string
bookName string
sourceGetter func(key string) string
sourcePutter func(key, value string)
ruleCache map[string][]*SourceRule
}
// NewAnalyzeRule 创建解析器。
func NewAnalyzeRule() *AnalyzeRule {
return &AnalyzeRule{ruleCache: map[string][]*SourceRule{}}
}
// SetJSRunner 注入 JS 执行器(P2)。
func (a *AnalyzeRule) SetJSRunner(runner func(js string, result any) (any, error)) {
a.jsRunner = runner
}
// SetContent 对应 setContent(content, baseUrl)。
func (a *AnalyzeRule) SetContent(content any, baseUrl string) *AnalyzeRule {
a.content = content
switch content.(type) {
case *html.Node:
a.isJSON = false
default:
a.isJSON = LooksLikeJSON(anyToString(content))
}
if baseUrl != "" {
a.baseUrl = baseUrl
}
return a
}
// SetBaseUrl 对应 setBaseUrl。
func (a *AnalyzeRule) SetBaseUrl(baseUrl string) *AnalyzeRule {
if baseUrl != "" {
a.baseUrl = baseUrl
}
return a
}
// SetRedirectUrl 对应 setRedirectUrl。
func (a *AnalyzeRule) SetRedirectUrl(u string) *AnalyzeRule {
if strings.HasPrefix(u, "data:") {
return a
}
if parsed, err := url.Parse(u); err == nil {
a.redirectURL = parsed
}
return a
}
// SetChapterContext 设置章节上下文(title 与章节级变量存储)。
func (a *AnalyzeRule) SetChapterContext(title string, vars map[string]string) {
a.chapterTitle = title
if vars != nil {
a.chapterVars = vars
}
}
// SetBookContext 设置书籍上下文(name 与书籍级变量存储)。
func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
a.bookName = name
if vars != nil {
a.bookVars = vars
}
}
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
a.sourceGetter = getter
a.sourcePutter = putter
}
// jsonpathGet 包装 PaesslerAG/jsonpath.Get。
func jsonpathGet(path string, root any) (v any, err error) {
defer func() {
if r := recover(); r != nil {
v, err = nil, ErrJsUnsupported
}
}()
return jsonpath.Get(path, root)
}
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
// Put 对应 put(key, value):chapter → book → 局部 → source。
func (a *AnalyzeRule) Put(key, value string) string {
switch {
case a.chapterVars != nil:
a.chapterVars[key] = value
case a.bookVars != nil:
a.bookVars[key] = value
default:
if a.vars == nil {
a.vars = map[string]string{}
}
a.vars[key] = value
}
return value
}
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
func (a *AnalyzeRule) Get(key string) string {
switch key {
case "bookName":
if a.bookName != "" {
return a.bookName
}
case "title":
if a.chapterTitle != "" {
return a.chapterTitle
}
}
for _, store := range []map[string]string{a.chapterVars, a.bookVars, a.vars} {
if store != nil {
if v, ok := store[key]; ok && v != "" {
return v
}
}
}
if a.sourceGetter != nil {
if v := a.sourceGetter(key); v != "" {
return v
}
}
return ""
}
// ─── JS ────────────────────────────────────────────────────────────────────
func (a *AnalyzeRule) evalJS(js string, result any) (any, error) {
if a.jsRunner == nil {
return nil, ErrJsUnsupported
}
return a.jsRunner(js, result)
}
// ─── 规则拆分缓存 ──────────────────────────────────────────────────────────
func (a *AnalyzeRule) splitSourceRuleCached(ruleStr string) []*SourceRule {
if ruleStr == "" {
return nil
}
if cached, ok := a.ruleCache[ruleStr]; ok {
return cached
}
rules := SplitSourceRule(ruleStr, false, a.isJSON, &a.isRegex)
a.ruleCache[ruleStr] = rules
return rules
}
func (a *AnalyzeRule) putRule(putMap map[string]string) error {
for k, v := range putMap {
s, err := a.GetString(v, nil, false)
if err != nil {
return err
}
a.Put(k, s)
}
return nil
}
func (a *AnalyzeRule) makeDeps() *RuleDeps {
return &RuleDeps{
JS: a.evalJS,
Rule: func(rule string) (string, error) { return a.GetString(rule, nil, false) },
Get: a.Get,
}
}
// ─── 主流程 ────────────────────────────────────────────────────────────────
// GetStringList 对应 getStringList(rule, mContent, isUrl)。
func (a *AnalyzeRule) GetStringList(ruleStr string, mContent any, isUrl bool) ([]string, error) {
if ruleStr == "" {
return nil, nil
}
ruleList := a.splitSourceRuleCached(ruleStr)
return a.getStringListRules(ruleList, mContent, isUrl)
}
func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, isUrl bool) ([]string, error) {
var result any
content := mContent
if content == nil {
content = a.content
}
if content != nil && len(ruleList) > 0 {
result = content
for _, sourceRule := range ruleList {
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
if err != nil {
return nil, err
}
if result == nil {
continue
}
rule := resolved.Rule
if rule != "" || resolved.ReplaceRegex == "" {
switch sourceRule.Mode {
case ModeWebJs:
return nil, ErrWebJSUnsupported
case ModeJs:
result, err = a.evalJS(rule, result)
if err != nil {
return nil, err
}
case ModeJson:
result = newJSONAnalyzer(result).getStringList(rule)
case ModeXPath:
result = newXPathAnalyzer(result).getStringList(rule)
case ModeDefault:
result = newJsoupAnalyzer(result).getStringList(rule)
default:
result = rule
}
}
if resolved.ReplaceRegex != "" {
if lst, ok := result.([]string); ok {
out := make([]string, 0, len(lst))
for _, item := range lst {
out = append(out, applyReplaceRegex(item, resolved))
}
result = out
} else {
result = applyReplaceRegex(resultString(result), resolved)
}
}
}
}
if result == nil {
return nil, nil
}
if s, ok := result.(string); ok {
result = strings.Split(s, "\n")
}
if isUrl {
var urlList []string
if lst, ok := result.([]string); ok {
for _, u := range lst {
abs := a.absolutize(u)
if abs != "" && !containsStr(urlList, abs) {
urlList = append(urlList, abs)
}
}
}
return urlList, nil
}
switch t := result.(type) {
case []string:
return t, nil
case []any:
out := make([]string, len(t))
for i, v := range t {
out[i] = anyToString(v)
}
return out, nil
case []*html.Node:
out := make([]string, len(t))
for i, v := range t {
out[i] = outerHTML(v)
}
return out, nil
default:
return []string{anyToString(result)}, nil
}
}
// GetString 对应 getString(rule, mContent, isUrl)。
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
if ruleStr == "" {
return "", nil
}
ruleList := a.splitSourceRuleCached(ruleStr)
return a.getStringRules(ruleList, mContent, isUrl)
}
func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl bool) (string, error) {
var result any
content := mContent
if content == nil {
content = a.content
}
if content != nil && len(ruleList) > 0 {
result = content
for _, sourceRule := range ruleList {
if err := a.putRule(sourceRule.putMap); err != nil {
return "", err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
if err != nil {
return "", err
}
if result == nil {
continue
}
rule := resolved.Rule
if rule != "" || resolved.ReplaceRegex == "" {
switch sourceRule.Mode {
case ModeWebJs:
return "", ErrWebJSUnsupported
case ModeJs:
result, err = a.evalJS(rule, result)
if err != nil {
return "", err
}
case ModeJson:
result = newJSONAnalyzer(result).getString(rule)
case ModeXPath:
result = newXPathAnalyzer(result).getString(rule)
case ModeDefault:
if isUrl {
result = newJsoupAnalyzer(result).getString0(rule)
} else {
result = newJsoupAnalyzer(result).getString(rule)
}
default:
result = rule
}
}
if result != nil && resolved.ReplaceRegex != "" {
result = applyReplaceRegex(resultString(result), resolved)
}
}
}
if result == nil {
result = ""
}
str := resultString(result)
if strings.Contains(str, "&") {
str = html.UnescapeString(str)
}
if isUrl {
if strings.TrimSpace(str) == "" {
return a.baseUrl, nil
}
return a.absolutize(str), nil
}
return str, nil
}
// GetElement 对应 getElement(ruleStr)。
func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) {
if ruleStr == "" {
return nil, nil
}
var result any
content := a.content
ruleList := SplitSourceRule(ruleStr, true, a.isJSON, &a.isRegex)
if content != nil && len(ruleList) > 0 {
result = content
for _, sourceRule := range ruleList {
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
if err != nil {
return nil, err
}
if result == nil {
continue
}
rule := resolved.Rule
switch sourceRule.Mode {
case ModeRegex:
result = regexGetElement(resultString(result), splitNotBlankAndTrim(rule, "&&"), 0)
case ModeWebJs:
return nil, ErrWebJSUnsupported
case ModeJs:
result, err = a.evalJS(rule, result)
if err != nil {
return nil, err
}
case ModeJson:
result = newJSONAnalyzer(result).getObject(rule)
case ModeXPath:
result = newXPathAnalyzer(result).getElements(rule)
default:
result = newJsoupAnalyzer(result).getElements(rule)
}
if resolved.ReplaceRegex != "" {
result = applyReplaceRegex(resultString(result), resolved)
}
}
}
return result, nil
}
// GetElements 对应 getElements(ruleStr):列表获取。
// 注意:与 Kotlin 一致,这里不做 makeUpRule(无 ## 正则段处理)。
func (a *AnalyzeRule) GetElements(ruleStr string) ([]any, error) {
var result any
content := a.content
ruleList := SplitSourceRule(ruleStr, true, a.isJSON, &a.isRegex)
if content != nil && len(ruleList) > 0 {
result = content
for _, sourceRule := range ruleList {
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
if result == nil {
continue
}
rule := sourceRule.Rule
var err error
switch sourceRule.Mode {
case ModeRegex:
result = regexGetElements(resultString(result), splitNotBlankAndTrim(rule, "&&"), 0)
case ModeWebJs:
return nil, ErrWebJSUnsupported
case ModeJs:
result, err = a.evalJS(rule, result)
if err != nil {
return nil, err
}
case ModeJson:
result = newJSONAnalyzer(result).getList(rule)
case ModeXPath:
result = newXPathAnalyzer(result).getElements(rule)
default:
result = newJsoupAnalyzer(result).getElements(rule)
}
}
}
if result != nil {
switch t := result.(type) {
case []any:
return t, nil
case []string:
out := make([]any, len(t))
for i, v := range t {
out[i] = v
}
return out, nil
case []*html.Node:
out := make([]any, len(t))
for i, v := range t {
out[i] = v
}
return out, nil
}
}
return []any{}, nil
}
// ─── 工具 ──────────────────────────────────────────────────────────────────
func (a *AnalyzeRule) absolutize(u string) string {
if strings.TrimSpace(u) == "" {
return ""
}
if a.redirectURL != nil {
return GetAbsoluteURLParsed(a.redirectURL, u)
}
return GetAbsoluteURL(a.baseUrl, u)
}
// applyReplaceRegex 对应 replaceRegex(result, resolved)。
func applyReplaceRegex(result string, r ResolvedSourceRule) string {
if r.ReplaceRegex == "" {
return result
}
if r.ReplaceFirst {
return regexReplaceFirstOnFirstMatch(r.ReplaceRegex, result, r.Replacement)
}
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
}
// resultString 对应 Kotlin 的 result.toString()。
func resultString(result any) string {
switch t := result.(type) {
case nil:
return ""
case string:
return t
case *html.Node:
return outerHTML(t)
case []string:
return strings.Join(t, "\n")
case []*html.Node:
var sb strings.Builder
for _, n := range t {
sb.WriteString(outerHTML(n))
}
return sb.String()
default:
return anyToString(result)
}
}