Files
MeBox/internal/service/reader/rule/url.go
T
truewhile 5976a8d310 feat(reader): P2——goja JS 引擎接入 + Go 原生 java.* 桥接
- rule/goja.go:纯 Go JS 运行时(goja),脚本编译缓存 + 超时中断(默认10s),
  绑定集合对齐 legado evalJS(java/cookie/cache/source/book/result/baseUrl/
  chapter/title/src/page/key)
- rule/bridge.go:java 对象函数名与 legado JsExtensions 一一对应(存量书源
  硬编码这些名字),全部 Go 原生实现:
  网络 ajax/ajaxAll/connect/get/post/head(经 MeBox HTTP 客户端,响应对象
  带 body()/code()/url()/header());规则回调 getString/getStringList/
  getElements/put/get(桥回当前解析器);编解码 base64/hex/md5(16/32)/
  digest/HMac/encodeURI/timeFormat(SimpleDateFormat 转 Go 布局)
- 对称加密:createSymmetricCrypto + aes/des/3des 全家桶,transformation
  三段式解析(AES/DES/3DES × CBC/ECB/CTR/OFB/CFB × PKCS5/7/Zero/NoPadding,
  ECB 手工块循环),密钥长度自动补齐,decryptStr 自动识别 Base64/Hex
- get(url,headers) 与 get(key) 按 arity 分发(对齐 Kotlin 重载)
- 浏览器/文件/压缩包类函数明确抛不支持(服务端沙箱边界)
- URL 规则 JS:analyzeJs(@result 链式)、{{js}} 内嵌、option js/bodyJs
- execute 记录 Set-Cookie 进 cookie jar(java/cookie.getCookie 可读)
- 测试:编码/摘要/AES 向量/CBC+ECB 往返/ajax httptest/URL JS/沙箱边界,
  全部通过;修复 java.get 双注册、测试缺 SetContent 与 md5Encode16 期望值
2026-09-30 16:36:32 +08:00

424 lines
12 KiB
Go
Raw 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 (
"encoding/json"
"regexp"
"strings"
"golang.org/x/text/encoding/htmlindex"
"golang.org/x/text/encoding/unicode"
)
// 本文件对应 AnalyzeUrl.kt 的 URL 解析部分(不含网络执行与 JS 执行)。
// URLOption 对应 AnalyzeUrl.UrlOption。
type URLOption struct {
Method string `json:"method"`
Charset string `json:"charset"`
Headers map[string]any `json:"headers"`
Body json.RawMessage `json:"body"`
Origin string `json:"origin"`
Retry *int `json:"retry"`
Type string `json:"type"`
WebView json.RawMessage `json:"webView"`
WebJs string `json:"webJs"`
DnsIp string `json:"dnsIp"`
Js string `json:"js"`
BodyJs string `json:"bodyJs"`
ServerID json.RawMessage `json:"serverID"`
WebViewDelayTime json.RawMessage `json:"webViewDelayTime"`
}
// Request 是解析后的可执行请求(供服务层执行)。
type Request struct {
URL string // 最终 URL(GET 时已含重编码后的 query)
URLNoQuery string
Method string
Headers map[string]string
Body string
IsForm bool // Body 为已编码的 form 数据
IsJSON bool // 以 application/json 发送
Charset string
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
BodyJsFn func(body string) string
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
// 值为对应错误(webView/type;JS 在接入 runner 后已支持)。
Unsupported error
}
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
// key/page 对应搜索关键词与页码({{key}}/{{page}}/<1,2,3>)。
func ParseAnalyzeUrl(mUrl, key string, page int, baseUrl string) (*Request, error) {
return ParseAnalyzeUrlWithJS(mUrl, key, page, baseUrl, nil)
}
// ParseAnalyzeUrlWithJS 在 ParseAnalyzeUrl 基础上支持 JS:
// URL 中的 <js>/@js: 块、{{js}} 内嵌、选项里的 js/bodyJs。
// runner 为 nil 时遇到 JS 标记 Unsupported(P0 兼容路径)。
func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *JSRunner) (*Request, error) {
req := &Request{Method: "GET", Headers: map[string]string{}}
// baseUrl 自身可能带 ",{...}" 选项段,先截断(对应 init 中的 paramPattern)
if st, _, ok := findParamSplit(baseUrl); ok {
baseUrl = baseUrl[:st]
}
ruleUrl := mUrl
// ── analyzeJs:URL 中的 <js>/@js:,@result 引用前序结果 ──
if jsPatternRe.MatchString(ruleUrl) {
if runner == nil {
req.Unsupported = ErrJsUnsupported
// 移除 JS 块继续解析,便于调试接口展示其余部分
ruleUrl = jsPatternRe.ReplaceAllString(ruleUrl, "")
} else {
// 对应 AnalyzeUrl.analyzeJs
start := 0
result := ruleUrl
for _, g := range jsPatternRe.FindAllStringSubmatchIndex(ruleUrl, -1) {
if g[0] > start {
if seg := strings.TrimSpace(ruleUrl[start:g[0]]); seg != "" {
result = strings.ReplaceAll(seg, "@result", anyToString(result))
}
}
jsBody := ""
if g[2] >= 0 {
jsBody = ruleUrl[g[2]:g[3]]
} else if g[4] >= 0 {
jsBody = ruleUrl[g[4]:g[5]]
}
v, err := runner.Run(nil, jsBody, result, baseUrl)
if err != nil {
return req, err
}
result = anyToString(v)
start = g[1]
}
if len(ruleUrl) > start {
if seg := strings.TrimSpace(ruleUrl[start:]); seg != "" {
result = strings.ReplaceAll(seg, "@result", result)
}
}
ruleUrl = result
}
}
// ── replaceKeyPageJs:{{...}} 与 <页码列表> ──
if strings.Contains(ruleUrl, "{{") && strings.Contains(ruleUrl, "}}") {
var subErr error
if runner == nil {
ra := NewRuleAnalyzer(ruleUrl, false)
out := ra.InnerRule2("{{", "}}", func(inner string) string {
trimmed := strings.TrimSpace(inner)
switch trimmed {
case "key":
return key
case "page":
p := page
if p < 1 {
p = 1
}
return anyToString(float64(p))
default:
subErr = ErrJsUnsupported
return ""
}
})
if subErr != nil {
req.Unsupported = subErr
} else if out != "" {
ruleUrl = out
}
} else {
// 对应 legado:{{...}} 一律按 JS 执行(key/page 为绑定变量)
ra := NewRuleAnalyzer(ruleUrl, false)
out := ra.InnerRule2("{{", "}}", func(inner string) string {
v, err := runner.Run(nil, strings.TrimSpace(inner), nil, baseUrl)
if err != nil {
subErr = err
return ""
}
return anyToString(v)
})
if subErr != nil {
return req, subErr
}
if out != "" {
ruleUrl = out
}
}
}
if page >= 1 {
for _, m := range pagePatternRe.FindAllStringSubmatch(ruleUrl, -1) {
pages := strings.Split(m[1], ",")
idx := page
if idx > len(pages) {
idx = len(pages)
}
ruleUrl = strings.ReplaceAll(ruleUrl, m[0], strings.TrimSpace(pages[idx-1]))
}
}
// ── analyzeUrl:分离 URL 与选项 JSON ──
ruleUrl = strings.TrimSpace(ruleUrl)
st, end, hasOpt := findParamSplit(ruleUrl)
urlNoOption := ruleUrl
if hasOpt {
urlNoOption = ruleUrl[:st]
}
urlNoOption = strings.TrimSpace(urlNoOption)
finalURL := GetAbsoluteURL(baseUrl, urlNoOption)
if b := GetBaseUrl(finalURL); b != "" {
baseUrl = b
}
req.URL = finalURL
if hasOpt {
optionStr := strings.TrimSpace(ruleUrl[end:])
var option URLOption
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
// 宽松重试:去掉可能的前后杂字符
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
return req, nil
}
}
switch strings.ToUpper(option.Method) {
case "POST":
req.Method = "POST"
case "HEAD":
req.Method = "HEAD"
}
for k, v := range option.Headers {
req.Headers[k] = anyToString(v)
}
if len(option.Body) > 0 {
var bodyStr string
if err := json.Unmarshal(option.Body, &bodyStr); err == nil {
req.Body = bodyStr
} else {
req.Body = string(option.Body)
}
}
req.Charset = option.Charset
if option.Type != "" && req.Unsupported == nil {
req.Unsupported = ErrTypeUnsupported
}
if option.WebJs != "" && req.Unsupported == nil {
req.Unsupported = ErrWebJSUnsupported
}
if useWebView(option.WebView) && req.Unsupported == nil {
req.Unsupported = ErrWebJSUnsupported
}
// 对应 AnalyzeUrl:option.js 在解析完成后执行,结果覆盖 url
if option.Js != "" && req.Unsupported == nil {
if runner == nil {
req.Unsupported = ErrJsUnsupported
} else if v, err := runner.Run(nil, option.Js, req.URL, baseUrl); err != nil {
return req, err
} else if s := anyToString(v); s != "" {
req.URL = s
}
}
// 对应 AnalyzeUrl:bodyJs 在响应后执行,结果作为 body
if option.BodyJs != "" && req.Unsupported == nil && runner != nil {
req.BodyJsFn = func(body string) string {
v, err := runner.Run(nil, option.BodyJs, body, baseUrl)
if err != nil {
return body
}
return anyToString(v)
}
} else if option.BodyJs != "" && req.Unsupported == nil {
req.Unsupported = ErrJsUnsupported
}
}
// ── query / body 编码(对应 analyzeUrl 尾部) ──
if req.Method == "POST" {
req.URLNoQuery = req.URL
body := req.Body
if body != "" && !isJSONStr(body) && !isXMLStr(body) && req.Headers["Content-Type"] == "" {
req.Body = encodeParams(body, req.Charset, false)
req.IsForm = true
} else if isJSONStr(body) && req.Headers["Content-Type"] == "" {
req.IsJSON = true
}
} else {
pos := strings.Index(req.URL, "?")
if pos != -1 {
query := encodeParams(req.URL[pos+1:], req.Charset, true)
req.URLNoQuery = req.URL[:pos]
if query != "" {
req.URL = req.URLNoQuery + "?" + query
} else {
req.URL = req.URLNoQuery
}
} else {
req.URLNoQuery = req.URL
}
}
return req, nil
}
var pagePatternRe = regexp.MustCompile(`<([^>]*)>`)
func useWebView(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
return b
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s != "" && s != "false"
}
return true
}
func isJSONStr(s string) bool {
t := strings.TrimSpace(s)
if !(strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) {
return false
}
return json.Valid([]byte(t))
}
func isXMLStr(s string) bool {
t := strings.TrimSpace(s)
return strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">")
}
// findParamSplit 对应 paramPattern \s*,\s*(?=\{) 的手动实现。
// 返回匹配起点(含逗号前空白)与选项 JSON 起点。
func findParamSplit(s string) (start, end int, ok bool) {
for i := 0; i < len(s); i++ {
if s[i] != ',' {
continue
}
st := i
for st > 0 && isSpaceByte(s[st-1]) {
st--
}
j := i + 1
for j < len(s) && isSpaceByte(s[j]) {
j++
}
if j < len(s) && s[j] == '{' {
return st, j, true
}
}
return 0, 0, false
}
func isSpaceByte(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
}
// encodeParams 对应 AnalyzeUrl.encodeParams。
func encodeParams(params, charset string, isQuery bool) string {
checkEncoded := charset == ""
cs := charset
if cs == "" {
cs = "utf-8"
}
if isQuery && cs != "escape" {
if encodedQuery(params) {
return params
}
return queryEncode(params, cs)
}
var sb strings.Builder
lenP := len(params)
pos := 0
for pos <= lenP {
if sb.Len() > 0 {
sb.WriteString("&")
}
ampOffset := strings.IndexByte(params[pos:], '&')
if ampOffset == -1 {
ampOffset = lenP
} else {
ampOffset += pos
}
eqOffset := strings.IndexByte(params[pos:ampOffset], '=')
if eqOffset == -1 {
sb.WriteString(appendEncodedStr(params[pos:ampOffset], checkEncoded, cs))
} else {
eqAbs := eqOffset + pos
sb.WriteString(appendEncodedStr(params[pos:eqAbs], checkEncoded, cs))
sb.WriteString("=")
sb.WriteString(appendEncodedStr(params[eqAbs+1:ampOffset], checkEncoded, cs))
}
pos = ampOffset + 1
}
return sb.String()
}
// appendEncodedStr 对应 StringBuilder.appendEncoded。
func appendEncodedStr(value string, checkEncoded bool, charset string) string {
if checkEncoded && encodedForm(value) {
return value
}
if charset == "escape" {
return JSEscape(value)
}
return javaURLEncode(value, charset)
}
// javaURLEncode 对应 java.net.URLEncoder.encode(value, charset):
// 字母数字与 .-*_ 保留,空格转 +,其余按字符集字节 %XX。
func javaURLEncode(value, charset string) string {
enc := encoderFor(charset)
var sb strings.Builder
for _, r := range value {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'),
r == '.', r == '-', r == '*', r == '_':
sb.WriteRune(r)
case r == ' ':
sb.WriteByte('+')
default:
for _, b := range enc(string(r)) {
const hex = "0123456789ABCDEF"
sb.WriteByte('%')
sb.WriteByte(hex[b>>4])
sb.WriteByte(hex[b&0xF])
}
}
}
return sb.String()
}
// queryEncode 对应 hutool RFC3986.UNRESERVED.orNew(...) 的 query 编码:
// 保留 notNeedEncodingQuery 集合内的字符,其余按字符集字节 %XX。
func queryEncode(params, charset string) string {
enc := encoderFor(charset)
var sb strings.Builder
for _, r := range params {
if r < 128 && notNeedEncodingQuery[r] {
sb.WriteRune(r)
continue
}
for _, b := range enc(string(r)) {
const hex = "0123456789ABCDEF"
sb.WriteByte('%')
sb.WriteByte(hex[b>>4])
sb.WriteByte(hex[b&0xF])
}
}
return sb.String()
}
func encoderFor(charset string) func(string) []byte {
if strings.EqualFold(charset, "utf-8") || strings.EqualFold(charset, "utf8") {
utf8Enc := unicode.UTF8
return func(s string) []byte { b, _ := utf8Enc.NewEncoder().Bytes([]byte(s)); return b }
}
enc, err := htmlindex.Get(charset)
if err != nil {
utf8Enc := unicode.UTF8
return func(s string) []byte { b, _ := utf8Enc.NewEncoder().Bytes([]byte(s)); return b }
}
return func(s string) []byte { b, _ := enc.NewEncoder().Bytes([]byte(s)); return b }
}