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

627 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 (
"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
}