Files
truewhile b8e6418326 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 前端使用)
2026-09-30 15:41:58 +08:00

521 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}