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
+626
View File
@@ -0,0 +1,626 @@
package rule
import (
"strings"
"golang.org/x/net/html"
)
// 本文件对应 AnalyzeByJSoup.kt(含 ElementsSingle 索引语法)。
type jsoupAnalyzer struct {
root *html.Node
}
func newJsoupAnalyzer(doc any) *jsoupAnalyzer {
return &jsoupAnalyzer{root: toHTMLNode(doc)}
}
// toHTMLNode 对应 AnalyzeByJSoup.parse(doc):节点直通,字符串解析为 HTML。
func toHTMLNode(doc any) *html.Node {
switch t := doc.(type) {
case nil:
return nil
case *html.Node:
return t
default:
return parseHTML(anyToString(t))
}
}
// getStringList 对应 AnalyzeByJSoup.getStringList。
func (a *jsoupAnalyzer) getStringList(ruleStr string) []string {
var textS []string
if ruleStr == "" {
return textS
}
isCss := false
elementsRule := ruleStr
if hasPrefixFold(ruleStr, "@CSS:") {
isCss = true
elementsRule = strings.TrimSpace(ruleStr[5:])
}
if elementsRule == "" {
if d := nodeData(a.root); d != "" {
textS = append(textS, d)
} else {
textS = append(textS, "")
}
return textS
}
ra := NewRuleAnalyzer(elementsRule, false)
ruleStrS := ra.SplitRule("&&", "||", "%%")
var results [][]string
for _, ruleStrX := range ruleStrS {
var temp []string
if isCss {
// 对应 isCss 分支:lastIndexOf('@') 分离选择器与提取规则
lastIndex := strings.LastIndex(ruleStrX, "@")
var elements []*html.Node
var lastRule string
if lastIndex == -1 {
elements = selectCSS(a.root, ruleStrX)
lastRule = "text"
} else {
selector := ruleStrX[:lastIndex]
if strings.TrimSpace(selector) == "" {
elements = []*html.Node{a.root}
} else {
elements = selectCSS(a.root, selector)
}
lastRule = ruleStrX[lastIndex+1:]
}
temp = getResultLast(elements, lastRule)
} else {
temp = a.getResultList(ruleStrX)
}
if len(temp) > 0 {
results = append(results, temp)
if ra.ElementsType() == "||" {
break
}
}
}
if len(results) > 0 {
if ra.ElementsType() == "%%" {
for i := 0; i < len(results[0]); i++ {
for _, temp := range results {
if i < len(temp) {
textS = append(textS, temp[i])
}
}
}
} else {
for _, temp := range results {
textS = append(textS, temp...)
}
}
}
return textS
}
// getString 对应 getString:多结果以 \n 连接。
func (a *jsoupAnalyzer) getString(ruleStr string) string {
list := a.getStringList(ruleStr)
if len(list) == 0 {
return ""
}
if len(list) == 1 {
return list[0]
}
return strings.Join(list, "\n")
}
// getString0 对应 getString0:只取第一个。
func (a *jsoupAnalyzer) getString0(ruleStr string) string {
list := a.getStringList(ruleStr)
if len(list) == 0 {
return ""
}
return list[0]
}
// getElements 对应 getElements。
func (a *jsoupAnalyzer) getElements(rule string) []*html.Node {
if rule == "" || a.root == nil {
return nil
}
isCss := false
elementsRule := rule
if hasPrefixFold(rule, "@CSS:") {
isCss = true
elementsRule = strings.TrimSpace(rule[5:])
}
ra := NewRuleAnalyzer(elementsRule, false)
ruleStrS := ra.SplitRule("&&", "||", "%%")
var elementsList [][]*html.Node
if isCss {
for _, ruleStr := range ruleStrS {
tempS := selectCSS(a.root, ruleStr)
elementsList = append(elementsList, tempS)
if len(tempS) > 0 && ra.ElementsType() == "||" {
break
}
}
} else {
for _, ruleStr := range ruleStrS {
rsRule := NewRuleAnalyzer(ruleStr, false)
rsRule.Trim()
rs := rsRule.SplitRule("@")
var el []*html.Node
if len(rs) > 1 {
el = []*html.Node{a.root}
for _, rl := range rs {
var es []*html.Node
for _, et := range el {
es = append(es, a.getElementsOf(et, rl)...)
}
el = es
}
} else {
es := newElementsSingle().getElementsSingle(a.root, ruleStr)
el = es
}
elementsList = append(elementsList, el)
if len(el) > 0 && ra.ElementsType() == "||" {
break
}
}
}
var elements []*html.Node
if len(elementsList) > 0 {
if ra.ElementsType() == "%%" {
for i := 0; i < len(elementsList[0]); i++ {
for _, es := range elementsList {
if i < len(es) {
elements = append(elements, es[i])
}
}
}
} else {
for _, es := range elementsList {
elements = append(elements, es...)
}
}
}
return elements
}
// getElementsOf 对应私有 getElements(temp, rule) 递归。
func (a *jsoupAnalyzer) getElementsOf(temp *html.Node, rule string) []*html.Node {
if temp == nil || rule == "" {
return nil
}
isCss := false
elementsRule := rule
if hasPrefixFold(rule, "@CSS:") {
isCss = true
elementsRule = strings.TrimSpace(rule[5:])
}
ra := NewRuleAnalyzer(elementsRule, false)
ruleStrS := ra.SplitRule("&&", "||", "%%")
var elementsList [][]*html.Node
if isCss {
for _, ruleStr := range ruleStrS {
tempS := selectCSS(temp, ruleStr)
elementsList = append(elementsList, tempS)
if len(tempS) > 0 && ra.ElementsType() == "||" {
break
}
}
} else {
for _, ruleStr := range ruleStrS {
rsRule := NewRuleAnalyzer(ruleStr, false)
rsRule.Trim()
rs := rsRule.SplitRule("@")
var el []*html.Node
if len(rs) > 1 {
el = []*html.Node{temp}
for _, rl := range rs {
var es []*html.Node
for _, et := range el {
es = append(es, a.getElementsOf(et, rl)...)
}
el = es
}
} else {
el = newElementsSingle().getElementsSingle(temp, ruleStr)
}
elementsList = append(elementsList, el)
if len(el) > 0 && ra.ElementsType() == "||" {
break
}
}
}
var elements []*html.Node
if len(elementsList) > 0 {
if ra.ElementsType() == "%%" {
for i := 0; i < len(elementsList[0]); i++ {
for _, es := range elementsList {
if i < len(es) {
elements = append(elements, es[i])
}
}
}
} else {
for _, es := range elementsList {
elements = append(elements, es...)
}
}
}
return elements
}
// getResultList 对应 getResultList:按 "@" 步进,最后一段作为提取规则。
func (a *jsoupAnalyzer) getResultList(ruleStr string) []string {
if ruleStr == "" {
return nil
}
elements := []*html.Node{a.root}
rule := NewRuleAnalyzer(ruleStr, false)
rule.Trim()
rules := rule.SplitRule("@")
last := len(rules) - 1
for i := 0; i < last; i++ {
var es []*html.Node
for _, elt := range elements {
es = append(es, newElementsSingle().getElementsSingle(elt, rules[i])...)
}
elements = es
}
if len(elements) == 0 {
return nil
}
return getResultLast(elements, rules[last])
}
// getResultLast 对应 getResultLast:按最后一个规则提取内容。
func getResultLast(elements []*html.Node, lastRule string) []string {
var textS []string
switch lastRule {
case "text":
for _, element := range elements {
if text := nodeText(element); text != "" {
textS = append(textS, text)
}
}
case "textNodes":
for _, element := range elements {
tn := nodeTextNodes(element)
if len(tn) > 0 {
textS = append(textS, strings.Join(tn, "\n"))
}
}
case "ownText":
for _, element := range elements {
if text := nodeOwnText(element); text != "" {
textS = append(textS, text)
}
}
case "html":
for _, element := range elements {
if h := outerHTMLNoScript(element); h != "" {
textS = append(textS, h)
}
}
case "all":
var sb strings.Builder
for _, element := range elements {
sb.WriteString(outerHTML(element))
}
textS = append(textS, sb.String())
default:
for _, element := range elements {
url := attrValue(element, lastRule)
if url == "" || containsStr(textS, url) {
continue
}
textS = append(textS, url)
}
}
return textS
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// ─── ElementsSingle:索引语法(对应 data class ElementsSingle) ─────────────
type indexRange struct {
start *int
end *int
step int
}
type elementsSingle struct {
split byte // '.' 选择 / '!' 排除 / ' ' 无索引
beforeRule string
indexDefault []int
indexes []any // int 或 indexRange
}
func newElementsSingle() *elementsSingle {
return &elementsSingle{split: '.'}
}
func (e *elementsSingle) getElementsSingle(temp *html.Node, rule string) []*html.Node {
e.findIndexSet(rule)
var elements []*html.Node
if e.beforeRule == "" {
elements = childrenElements(temp)
} else {
rules := strings.Split(e.beforeRule, ".")
arg := ""
if len(rules) > 1 {
arg = rules[1]
}
switch rules[0] {
case "children":
elements = childrenElements(temp)
case "class":
if arg != "" {
elements = getElementsByClass(temp, arg)
}
case "tag":
if arg != "" {
elements = getElementsByTag(temp, arg)
}
case "id":
if arg != "" {
elements = getElementsById(temp, arg)
}
case "text":
if arg != "" {
elements = getElementsContainingOwnText(temp, arg)
}
default:
elements = selectCSS(temp, e.beforeRule)
}
}
// 索引集合:slice+set 模拟 Kotlin LinkedHashSet 的插入顺序去重
indexSet := make([]int, 0, len(elements))
seen := make(map[int]bool)
addIndex := func(ix int) {
if !seen[ix] {
seen[ix] = true
indexSet = append(indexSet, ix)
}
}
lenn := len(elements)
lastIndexes := -1
if len(e.indexDefault) > 0 {
lastIndexes = len(e.indexDefault) - 1
} else if len(e.indexes) > 0 {
lastIndexes = len(e.indexes) - 1
}
if len(e.indexes) == 0 {
// 旧式索引:逆向遍历还原顺序
for ix := lastIndexes; ix >= 0; ix-- {
it := e.indexDefault[ix]
if it >= 0 && it < lenn {
addIndex(it)
} else if it < 0 && lenn >= -it {
addIndex(it + lenn)
}
}
} else {
for ix := lastIndexes; ix >= 0; ix-- {
if rg, ok := e.indexes[ix].(indexRange); ok {
start := 0
if rg.start != nil {
start = *rg.start
}
if start < 0 {
start += lenn
}
end := lenn - 1
if rg.end != nil {
end = *rg.end
}
if end < 0 {
end += lenn
}
if (start < 0 && end < 0) || (start >= lenn && end >= lenn) {
continue
}
if start >= lenn {
start = lenn - 1
} else if start < 0 {
start = 0
}
if end >= lenn {
end = lenn - 1
} else if end < 0 {
end = 0
}
if start == end || rg.step >= lenn {
addIndex(start)
continue
}
step := rg.step
if step > 0 {
// 正向步长原样使用
} else if -step < lenn {
step = step + lenn
} else {
step = 1
}
if end > start {
for i := start; i <= end; i += step {
addIndex(i)
}
} else {
for i := start; i >= end; i -= step {
addIndex(i)
}
}
} else {
it := e.indexes[ix].(int)
if it >= 0 && it < lenn {
addIndex(it)
} else if it < 0 && lenn >= -it {
addIndex(it + lenn)
}
}
}
}
if e.split == '!' {
exclude := make(map[int]bool)
for _, ix := range indexSet {
exclude[ix] = true
}
var es []*html.Node
for i, el := range elements {
if !exclude[i] {
es = append(es, el)
}
}
elements = es
} else if e.split == '.' {
var es []*html.Node
for _, ix := range indexSet {
if ix >= 0 && ix < lenn {
es = append(es, elements[ix])
}
}
elements = es
}
return elements
}
// findIndexSet 对应 ElementsSingle.findIndexSet:从右向左解析索引。
// 支持旧式 tag.div.-1:10:2 / tag.div!0:3 与新式 tag.div[!-1, 3:-2:-10, 2]。
func (e *elementsSingle) findIndexSet(rule string) {
rus := []rune(strings.TrimSpace(rule))
n := len(rus)
if n == 0 {
e.split = ' '
e.beforeRule = ""
return
}
var curList []*int // 区间临时列表(逆向压入:右端、左端、间隔)
l := "" // 暂存数字字符串
curMinus := false
head := rus[n-1] == ']'
length := n
if head {
length-- // 跳过尾部 ']'
}
findLoop:
for length >= 0 {
length--
if length < 0 {
break
}
rl := rus[length]
if rl == ' ' {
continue
}
if rl >= '0' && rl <= '9' {
l = string(rl) + l
continue
}
if rl == '-' {
curMinus = true
continue
}
var curInt *int
if l != "" {
v := parseIntSafe(l)
if curMinus {
v = -v
}
curInt = &v
}
if head {
switch rl {
case ':':
curList = append(curList, curInt)
default:
if len(curList) == 0 {
if curInt == nil {
break findLoop // 是 jsoup 选择器而非索引列表
}
e.indexes = append(e.indexes, *curInt)
} else {
rg := indexRange{start: curInt, end: curList[len(curList)-1], step: 1}
if len(curList) == 2 && curList[0] != nil {
rg.step = *curList[0]
}
e.indexes = append(e.indexes, rg)
curList = curList[:0]
}
if rl == '!' {
e.split = '!'
for {
length--
if length < 0 {
break
}
rl = rus[length]
if rl != ' ' {
break
}
}
}
if rl == '[' {
if length < 0 {
length = 0
}
e.beforeRule = string(rus[:length])
return
}
if rl != ',' {
break findLoop
}
}
} else {
if rl == '!' || rl == '.' || rl == ':' {
v := 0
if curInt != nil {
v = *curInt
}
e.indexDefault = append(e.indexDefault, v)
if rl != ':' {
e.split = byte(rl)
e.beforeRule = string(rus[:length])
return
}
} else {
break findLoop
}
}
l = ""
curMinus = false
}
e.split = ' '
e.beforeRule = string(rus)
}
func parseIntSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}