mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
b8e6418326
- 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 前端使用)
521 lines
13 KiB
Go
521 lines
13 KiB
Go
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)
|
||
}
|
||
}
|