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
+180
View File
@@ -0,0 +1,180 @@
package rule
import (
"fmt"
"net/url"
"strings"
"golang.org/x/text/encoding/htmlindex"
"golang.org/x/text/encoding/unicode"
)
// 本文件对应 legado 的 NetworkUtils.kt / EncoderUtils.kt 中与规则引擎相关的函数。
// GetAbsoluteURL 对应 NetworkUtils.getAbsoluteURL(baseURL: String?, relativePath)。
// baseURL 会先截掉 ",{...}" 选项段(substringBefore(","))。
func GetAbsoluteURL(baseURL, relativePath string) string {
rel := strings.TrimSpace(relativePath)
if isAbsURL(rel) || isDataURL(rel) || strings.HasPrefix(rel, "javascript") {
if strings.HasPrefix(rel, "javascript") {
return ""
}
return rel
}
if baseURL == "" || isDataURL(baseURL) {
return rel
}
base := baseURL
if i := strings.Index(base, ","); i >= 0 {
base = base[:i]
}
baseURLParsed, err := url.Parse(strings.TrimSpace(base))
if err != nil {
return rel
}
return GetAbsoluteURLParsed(baseURLParsed, rel)
}
// GetAbsoluteURLParsed 对应 NetworkUtils.getAbsoluteURL(baseURL: URL?, relativePath)。
func GetAbsoluteURLParsed(base *url.URL, relativePath string) string {
rel := strings.TrimSpace(relativePath)
if base == nil {
return rel
}
if isAbsURL(rel) || isDataURL(rel) {
return rel
}
if strings.HasPrefix(rel, "javascript") {
return ""
}
ref, err := url.Parse(rel)
if err != nil {
return rel
}
return base.ResolveReference(ref).String()
}
// GetBaseUrl 对应 NetworkUtils.getBaseUrl:scheme://host[:port]。
func GetBaseUrl(u string) string {
if len(u) >= 8 && (strings.EqualFold(u[:7], "http://") || (len(u) >= 9 && strings.EqualFold(u[:8], "https://"))) {
if idx := strings.Index(u[8:], "/"); idx >= 0 {
return u[:8+idx]
}
return u
}
return ""
}
// isAbsURL 对应 String.isAbsUrl()。
func isAbsURL(s string) bool {
return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://")
}
func isDataURL(s string) bool {
return strings.HasPrefix(s, "data:")
}
// notNeedEncodingQuery / notNeedEncodingForm 对应 NetworkUtils 的两个 BitSet。
var (
notNeedEncodingQuery = buildEncodingSet("!$&()*+,-./:;=?@[\\]^_`{|}~")
notNeedEncodingForm = buildEncodingSet("*-._")
)
func buildEncodingSet(extra string) map[rune]bool {
set := make(map[rune]bool, 128)
for r := 'a'; r <= 'z'; r++ {
set[r] = true
}
for r := 'A'; r <= 'Z'; r++ {
set[r] = true
}
for r := '0'; r <= '9'; r++ {
set[r] = true
}
for _, r := range extra {
set[r] = true
}
return set
}
func isDigit16(r byte) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
// encodedQuery 对应 NetworkUtils.encodedQuery(str):判断字符串是否已按
// urlEncode 规范编码(无需再编码返回 true)。
func encodedQuery(s string) bool {
return encodedWith(s, notNeedEncodingQuery)
}
// encodedForm 对应 NetworkUtils.encodedForm(str)。
func encodedForm(s string) bool {
return encodedWith(s, notNeedEncodingForm)
}
func encodedWith(s string, allow map[rune]bool) bool {
rs := []rune(s)
for i := 0; i < len(rs); i++ {
r := rs[i]
if r < 128 && allow[r] {
continue
}
if r == '%' && i+2 < len(rs) && isDigit16(byte(rs[i+1])) && isDigit16(byte(rs[i+2])) {
i += 2
continue
}
return false
}
return len(rs) > 0
}
// JSEscape 对应 EncoderUtils.escape(JS escape() 语义)。
func JSEscape(src string) string {
var sb strings.Builder
for _, r := range src {
code := int(r)
if (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) {
sb.WriteRune(r)
continue
}
switch {
case code < 16:
fmt.Fprintf(&sb, "%%0%X", code)
case code < 256:
fmt.Fprintf(&sb, "%%%X", code)
default:
fmt.Fprintf(&sb, "%%u%04X", code)
}
}
return sb.String()
}
// DecodeBytes 按字符集名解码字节流,name 为空时按 UTF-8(带 BOM 处理)。
func DecodeBytes(b []byte, name string) (string, error) {
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
return string(b[3:]), nil
}
if name == "" || strings.EqualFold(name, "utf-8") || strings.EqualFold(name, "utf8") {
dec := unicode.UTF8.NewDecoder()
out, err := dec.Bytes(b)
if err != nil {
return string(b), nil
}
return string(out), nil
}
enc, err := htmlindex.Get(name)
if err != nil {
return string(b), nil
}
out, derr := enc.NewDecoder().Bytes(b)
if derr != nil {
return string(b), nil
}
return string(out), nil
}
// LooksLikeJSON 对应 String.isJson()(宽松:trim 后以 { 或 [ 开头)。
func LooksLikeJSON(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[")
}