diff --git a/cmd/reader-smoke/main.go b/cmd/reader-smoke/main.go
index c6e6822..cba4c3a 100644
--- a/cmd/reader-smoke/main.go
+++ b/cmd/reader-smoke/main.go
@@ -14,6 +14,7 @@ import (
"fmt"
"net/http"
"os"
+ "path/filepath"
"strings"
"sync"
"time"
@@ -76,7 +77,15 @@ func main() {
fatal("未从输入中识别到书源")
}
- svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
+ // 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
+ // 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
+ // 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
+ cfg := &config.Config{}
+ cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
+ if err := os.MkdirAll(cacheDir, 0o750); err == nil {
+ cfg.Cache.CacheDir = cacheDir
+ }
+ svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
ctx := context.Background()
results := make([]*reader.SmokeChainResult, len(sources))
diff --git a/internal/helper/http.go b/internal/helper/http.go
index 5d58720..5174038 100644
--- a/internal/helper/http.go
+++ b/internal/helper/http.go
@@ -104,10 +104,19 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
//
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
+// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
+// `text/html,application/xhtml+xml,...`。
+//
+// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
+// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
+// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
+// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
+// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
+// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
func HTTPHeaderPresets() map[string]string {
return map[string]string{
"User-Agent": defaultUserAgent,
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
+ "Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
diff --git a/internal/helper/http_test.go b/internal/helper/http_test.go
index d383930..46326df 100644
--- a/internal/helper/http_test.go
+++ b/internal/helper/http_test.go
@@ -12,6 +12,18 @@ import (
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
+// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
+//
+// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
+// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
+// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
+func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
+ got := HTTPHeaderPresets()["Accept"]
+ if got != "*/*" {
+ t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
+ }
+}
+
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index 983f741..0faef71 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -13,6 +13,7 @@ import (
"io"
"net/http"
"net/url"
+ "path/filepath"
"regexp"
"sort"
"strings"
@@ -404,6 +405,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
break
}
data = helper.DecompressBody(resp, data)
+ // Raw:按原始字节返回(书源文件落盘用),不做 charset 解码。
+ // 必须先于 HexBody 判断之外处理:GBK 文本一旦按 UTF-8 解码就变成 U+FFFD,
+ // 落盘的文件会坏掉。
+ if req.Raw {
+ return string(data), resp.Request.URL.String(), resp.StatusCode, nil
+ }
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
// 不做 charset 解码——书源会自己 hexDecodeToString 取回内容。
if req.HexBody {
@@ -641,9 +648,28 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
JSLib: SPtr(sess.bs.JSLib),
Ctx: sess.ctx,
Browser: host,
+ // 书源文件缓存根目录:java.downloadFile / cacheFile 落盘用。
+ CacheDir: sess.svc.readerFileCacheDir(),
})
}
+// readerFileCacheDir 书源文件缓存根目录(java.downloadFile / cacheFile)。
+// 优先用配置的 cache_dir,退回 data_dir/cache;都不可用时返回空串,
+// 此时文件类 JS 接口会抛出「未配置缓存目录」而不是写到进程当前目录。
+func (s *ReaderService) readerFileCacheDir() string {
+ if s == nil || s.cfg == nil {
+ return ""
+ }
+ if base := strings.TrimSpace(s.cfg.Cache.CacheDir); base != "" {
+ return base
+ }
+ if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
+ return filepath.Join(dataDir, "cache")
+ }
+ return ""
+}
+
+
// srcID 书源记录 ID(登录界面待办按书源隔离)。
func (sess *sourceSession) srcID() string {
if sess.src != nil {
diff --git a/internal/service/reader/reader_copy_source_test.go b/internal/service/reader/reader_copy_source_test.go
new file mode 100644
index 0000000..96d244e
--- /dev/null
+++ b/internal/service/reader/reader_copy_source_test.go
@@ -0,0 +1,121 @@
+package reader
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "golang.org/x/text/encoding/simplifiedchinese"
+)
+
+// 本文件:拷贝系列轻小说源的端到端回归。
+//
+// 合成一个「照搬拷贝轻小说写法」的书源,一次覆盖四个修复点:
+// 1. 搜索列表规则 `results.list.[*]`(legado 的 `.[*]` 形式);
+// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()/ArrayList()` 拼装;
+// 3. 正文规则用 `java.cacheFile(url)` 取整卷文本;
+// 4. 正文是 GBK 文本——必须走 Raw 原始字节通道落盘,否则会被 UTF-8 解码成乱码。
+
+func TestCopyLightNovelSourceEndToEnd(t *testing.T) {
+ var srv *httptest.Server
+ gbkBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("第一章 开始\n第二章 继续"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mux := http.NewServeMux()
+ // 搜索:Django REST 风格的 JSON。q_type/_update 参数照抄真实源。
+ mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"code":200,"results":{"list":[
+ {"name":"转生成为史莱姆","path_word":"slime","author":[{"name":"作者甲"}],"cover":"https://img.example.com/1.jpg"}
+ ]}}`))
+ })
+ mux.HandleFunc("/api/book/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"code":200,"results":{"book":{"name":"转生成为史莱姆","path_word":"slime"}}}`))
+ })
+ mux.HandleFunc("/api/volumes", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"code":200,"results":{"list":[{"name":"第一卷","id":7}]}}`))
+ })
+ mux.HandleFunc("/api/volume/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"code":200,"results":{"volume":{"txt_addr":"%s/api/txt/7","txt_encoding":"GBK"}}}`, srv.URL)
+ })
+ mux.HandleFunc("/api/txt/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write(gbkBody)
+ })
+ srv = httptest.NewServer(mux)
+ defer srv.Close()
+
+ source := map[string]any{
+ "bookSourceName": "合成拷贝轻小说源",
+ "bookSourceUrl": srv.URL,
+ "enabled": true,
+ "enabledExplore": true,
+ "searchUrl": srv.URL + "/api/search?limit=20&q_type=&q={{key}}&_update=true",
+ "ruleSearch": map[string]any{
+ "bookList": "results.list.[*]",
+ "name": "$.name",
+ "author": "$.author[*].name",
+ "coverUrl": "$.cover",
+ "bookUrl": srv.URL + "/api/book/{{$.path_word}}?_update=true",
+ },
+ "ruleBookInfo": map[string]any{
+ "init": "$.results.book",
+ "name": "$.name",
+ "intro": "$.brief",
+ "tocUrl": srv.URL + "/api/volumes?book={{$.path_word}}&_update=true",
+ },
+ "ruleToc": map[string]any{
+ "chapterList": `
+function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
+var base = '` + srv.URL + `/api/volume/';
+var out = new Packages.java.util.ArrayList();
+var vols = JSON.parse(result).results.list;
+for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id + '?_update=true')); }
+out;`,
+ "chapterName": "$.name",
+ "chapterUrl": "$.url",
+ },
+ "ruleContent": map[string]any{
+ "content": `@js:java.cacheFile(java.getString('$..txt_addr'))`,
+ },
+ }
+ raw, err := json.Marshal(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ svc, _ := newLoginTestService(t)
+ svc.cfg.Cache.CacheDir = t.TempDir()
+
+ res := svc.SmokeSource(t.Context(), string(raw), "史莱姆")
+ if !res.OK {
+ t.Fatalf("链路失败于 %s: %s\n%s", res.FailedAt, res.Error, joinLogs(res))
+ }
+ if res.SearchHits != 1 {
+ t.Fatalf("搜索命中=%d, want 1(`.[*]` 路径修复)", res.SearchHits)
+ }
+ if res.Chapters != 1 {
+ t.Fatalf("章节数=%d, want 1(Packages 修复)", res.Chapters)
+ }
+ if res.ContentLen == 0 {
+ t.Fatalf("正文长度=0(cacheFile/Raw 修复未生效)\n%s", joinLogs(res))
+ }
+ if !strings.Contains(joinLogs(res), "第一章") {
+ t.Fatalf("正文未正确解码(GBK 落盘被 utf-8 破坏)\n%s", joinLogs(res))
+ }
+}
+
+func joinLogs(res *SmokeChainResult) string {
+ var b strings.Builder
+ for _, l := range res.Logs {
+ fmt.Fprintf(&b, "[%s/%s] %s\n", l.Stage, l.Level, l.Message)
+ }
+ return b.String()
+}
diff --git a/internal/service/reader/rule/bridge.go b/internal/service/reader/rule/bridge.go
index 75a4955..8a9a82a 100644
--- a/internal/service/reader/rule/bridge.go
+++ b/internal/service/reader/rule/bridge.go
@@ -10,6 +10,8 @@ import (
"encoding/hex"
"fmt"
"hash"
+ "os"
+ "path/filepath"
"strings"
"github.com/dop251/goja"
@@ -435,6 +437,95 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
return goja.Null()
})
+ // ── 本地文件(对应 legado JsExtensions 的缓存/文件系列) ──
+ //
+ // 服务端把文件限制在自己的缓存目录(/reader/files)内,
+ // 书源只能用相对路径(允许以 / 开头,downloadFile 的返回值即此形态)。
+ set("downloadFile", func(call goja.FunctionCall) goja.Value {
+ // 旧签名 downloadFile(contentHex, url):把 hex 字符串落成文件。
+ if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
+ url := stringArg(call, 1)
+ raw, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
+ if err != nil {
+ bridgeErr("downloadFile", err)
+ }
+ rel, err := writeCacheFile(r.cfg.CacheDir, cacheFileName(url), raw)
+ if err != nil {
+ bridgeErr("downloadFile", err)
+ }
+ return vm.ToValue(rel)
+ }
+ rel, err := downloadToCache(r, stringArg(call, 0))
+ if err != nil {
+ bridgeErr("downloadFile", err)
+ }
+ return vm.ToValue(rel)
+ })
+ // cacheFile(url): 下载并缓存文本文件,返回文件**内容**(对应 legado 语义)。
+ set("cacheFile", func(call goja.FunctionCall) goja.Value {
+ url := stringArg(call, 0)
+ if dir := readerFilesDir(r.cfg.CacheDir); dir != "" {
+ if b, err := os.ReadFile(filepath.Join(dir, cacheFileName(url))); err == nil && len(b) > 0 {
+ return vm.ToValue(decodeTextAuto(b))
+ }
+ }
+ rel, err := downloadToCache(r, url)
+ if err != nil {
+ bridgeErr("cacheFile", err)
+ }
+ full, err := resolveCacheFilePath(r.cfg.CacheDir, rel)
+ if err != nil {
+ bridgeErr("cacheFile", err)
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ bridgeErr("cacheFile", err)
+ }
+ return vm.ToValue(decodeTextAuto(b))
+ })
+ set("readFile", func(call goja.FunctionCall) goja.Value {
+ full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
+ if err != nil {
+ return goja.Null()
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ return goja.Null()
+ }
+ return vm.ToValue(vm.NewArrayBuffer(b))
+ })
+ set("readTxtFile", func(call goja.FunctionCall) goja.Value {
+ full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
+ if err != nil {
+ return vm.ToValue("")
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ return vm.ToValue("")
+ }
+ if cs := stringArgOr(call, 1, ""); cs != "" {
+ if s, derr := DecodeBytes(b, cs); derr == nil {
+ return vm.ToValue(s)
+ }
+ }
+ return vm.ToValue(decodeTextAuto(b))
+ })
+ set("deleteFile", func(call goja.FunctionCall) goja.Value {
+ full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
+ if err != nil {
+ return vm.ToValue(false)
+ }
+ return vm.ToValue(os.Remove(full) == nil)
+ })
+ // getFile(path): 返回 File 对象(提供书源常用的 exists/length/name/delete/readText)。
+ set("getFile", func(call goja.FunctionCall) goja.Value {
+ full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
+ if err != nil {
+ full = ""
+ }
+ return newFileObject(vm, full, stringArg(call, 0))
+ })
+
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
if r.state != nil {
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
@@ -540,13 +631,15 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
set(name, unsupported(name, "需要无头浏览器(WebView)"))
}
for _, name := range []string{
- "importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
"getZipStringContent", "getZipByteArrayContent",
"getRarStringContent", "get7zStringContent",
} {
- set(name, unsupported(name, "需要本地文件/压缩包能力"))
+ set(name, unsupported(name, "需要压缩包解压能力"))
}
+ // importScript 在 legado 里是「下载 JS 文件并 eval」,服务端可做但会引入
+ // 任意脚本执行面,暂不支持,保持明确报错。
+ set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
return o
}
diff --git a/internal/service/reader/rule/bridge_files.go b/internal/service/reader/rule/bridge_files.go
new file mode 100644
index 0000000..751563c
--- /dev/null
+++ b/internal/service/reader/rule/bridge_files.go
@@ -0,0 +1,209 @@
+package rule
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/dop251/goja"
+)
+
+// 本文件实现 legado JsExtensions 里的「本地文件」能力
+// (cacheFile / downloadFile / readFile / readTxtFile / deleteFile / getFile)。
+//
+// 与 legado 的语义保持一致:
+// - downloadFile(url) 下载到缓存目录,返回「相对缓存根目录」的路径;
+// - cacheFile(url) 下载(带缓存)后返回文件的**文本内容**(不是路径);
+// - readFile/readTxtFile/deleteFile/getFile 接受相对路径(允许以 / 开头),
+// 一律解析到缓存目录下。
+//
+// 服务端把缓存根目录固定为 /reader/files,并且只允许访问该目录内的文件,
+// 避免书源通过 `../../` 之类读到宿主上的任意文件。
+
+// filesSubdir 书源文件缓存目录(相对 ReaderService 的缓存根目录)。
+const filesSubdir = "reader/files"
+
+// readerFilesDir 返回书源文件缓存目录;未配置缓存目录时返回空串。
+func readerFilesDir(cacheDir string) string {
+ if strings.TrimSpace(cacheDir) == "" {
+ return ""
+ }
+ return filepath.Join(cacheDir, "reader", "files")
+}
+
+// cacheFileName 计算某个 URL 在缓存目录里的文件名(对应 legado 的
+// `${md5Encode16(url)}.${type}`:md5 十六进制取中间 16 位,保留 URL 后缀)。
+func cacheFileName(rawURL string) string {
+ name := md5Hex(rawURL, true)
+ if ext := urlFileSuffix(rawURL); ext != "" {
+ name += "." + ext
+ }
+ return name
+}
+
+// urlFileSuffix 取 URL 路径部分的后缀(不含 `.`),只接受字母数字(长度 1~8)。
+func urlFileSuffix(rawURL string) string {
+ u := rawURL
+ if i := strings.IndexAny(u, "?#"); i >= 0 {
+ u = u[:i]
+ }
+ if i := strings.LastIndex(u, "/"); i >= 0 {
+ u = u[i+1:]
+ }
+ dot := strings.LastIndex(u, ".")
+ if dot < 0 || dot == len(u)-1 {
+ return ""
+ }
+ ext := u[dot+1:]
+ if len(ext) > 8 {
+ return ""
+ }
+ for i := 0; i < len(ext); i++ {
+ c := ext[i]
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
+ return ""
+ }
+ }
+ return strings.ToLower(ext)
+}
+
+// resolveCacheFilePath 把书源给的相对路径解析成缓存目录下的绝对路径。
+// 允许以 `/` 开头(downloadFile 的返回值就是这种形态),但拒绝越出缓存目录。
+func resolveCacheFilePath(cacheDir, p string) (string, error) {
+ dir := readerFilesDir(cacheDir)
+ if dir == "" {
+ return "", fmt.Errorf("未配置缓存目录")
+ }
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return "", fmt.Errorf("文件路径为空")
+ }
+ rel := filepath.Clean(filepath.FromSlash(strings.Trim(p, `/\`)))
+ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("非法文件路径: %s", p)
+ }
+ return filepath.Join(dir, rel), nil
+}
+
+// writeCacheFile 把字节写入缓存目录,返回相对路径(以 / 开头,对应 legado)。
+func writeCacheFile(cacheDir, name string, data []byte) (string, error) {
+ dir := readerFilesDir(cacheDir)
+ if dir == "" {
+ return "", fmt.Errorf("未配置缓存目录")
+ }
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
+ return "", err
+ }
+ return "/" + name, nil
+}
+
+// downloadToCache 下载 URL 并落到缓存目录(已存在则直接复用),
+// 返回相对缓存根目录的路径。下载走书源会话,因此会带上书源请求头与 Cookie。
+func downloadToCache(r *JSRunner, rawURL string) (string, error) {
+ dir := readerFilesDir(r.cfg.CacheDir)
+ if dir == "" {
+ return "", fmt.Errorf("未配置缓存目录")
+ }
+ name := cacheFileName(rawURL)
+ full := filepath.Join(dir, name)
+ if st, err := os.Stat(full); err == nil && st.Size() > 0 {
+ return "/" + name, nil
+ }
+ req, err := ParseAnalyzeUrlWithJS(rawURL, "", 0, r.cfg.BaseURL, r)
+ if err != nil {
+ return "", err
+ }
+ if req.Unsupported != nil {
+ return "", req.Unsupported
+ }
+ // Raw:响应按原始字节返回,避免 charset 解码破坏二进制/非 UTF-8 文本。
+ // 同时清掉 HexBody:URL 选项里的 `type` 在 legado 是文件后缀提示,
+ // 这里不该把下载内容按 hex 字符串落盘。
+ req.Raw = true
+ req.HexBody = false
+ body, _, code, err := r.fetch(req)
+ if err != nil {
+ return "", err
+ }
+ if code >= 400 {
+ return "", fmt.Errorf("下载失败 HTTP %d: %s", code, rawURL)
+ }
+ return writeCacheFile(r.cfg.CacheDir, name, []byte(body))
+}
+
+// decodeTextAuto 解码文本文件:UTF-8 BOM → 去 BOM;合法 UTF-8 → 原样;
+// 否则按 GBK 解(中文站点/书源最常见的另一种编码),失败再原样返回。
+func decodeTextAuto(b []byte) string {
+ if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
+ return string(b[3:])
+ }
+ if utf8.Valid(b) {
+ return string(b)
+ }
+ if s, err := DecodeBytes(b, "gbk"); err == nil && s != "" {
+ return s
+ }
+ return string(b)
+}
+
+// fileSuffixFromName 取路径的最后一段(getFile 的 name 语义)。
+func fileSuffixFromName(p string) string {
+ return path.Base(strings.ReplaceAll(p, "\\", "/"))
+}
+
+// newFileObject 构造 `java.getFile(path)` 返回的 File 等价物,
+// 提供书源常用的 exists / length / name / delete / readText。
+func newFileObject(vm *goja.Runtime, full, raw string) *goja.Object {
+ o := vm.NewObject()
+ set := func(k string, v any) { _ = o.Set(k, v) }
+ set("path", full)
+ set("name", fileSuffixFromName(raw))
+ set("exists", func(goja.FunctionCall) goja.Value {
+ if full == "" {
+ return vm.ToValue(false)
+ }
+ _, err := os.Stat(full)
+ return vm.ToValue(err == nil)
+ })
+ set("isDirectory", func(goja.FunctionCall) goja.Value {
+ if full == "" {
+ return vm.ToValue(false)
+ }
+ st, err := os.Stat(full)
+ return vm.ToValue(err == nil && st.IsDir())
+ })
+ set("length", func(goja.FunctionCall) goja.Value {
+ if full == "" {
+ return vm.ToValue(0)
+ }
+ st, err := os.Stat(full)
+ if err != nil {
+ return vm.ToValue(0)
+ }
+ return vm.ToValue(st.Size())
+ })
+ set("delete", func(goja.FunctionCall) goja.Value {
+ if full == "" {
+ return vm.ToValue(false)
+ }
+ return vm.ToValue(os.Remove(full) == nil)
+ })
+ set("readText", func(goja.FunctionCall) goja.Value {
+ if full == "" {
+ return vm.ToValue("")
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ return vm.ToValue("")
+ }
+ return vm.ToValue(decodeTextAuto(b))
+ })
+ set("toString", func(goja.FunctionCall) goja.Value { return vm.ToValue(full) })
+ return o
+}
diff --git a/internal/service/reader/rule/files_test.go b/internal/service/reader/rule/files_test.go
new file mode 100644
index 0000000..53157e9
--- /dev/null
+++ b/internal/service/reader/rule/files_test.go
@@ -0,0 +1,152 @@
+package rule
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "golang.org/x/text/encoding/simplifiedchinese"
+)
+
+// 本文件:java 文件接口(cacheFile / downloadFile / readTxtFile / deleteFile)
+// 的语义回归。对应 legado JsExtensions:
+// - cacheFile(url) → 返回文件**文本内容**(不是路径);
+// - downloadFile(url) → 返回相对缓存根目录的路径;
+// - readTxtFile(path[, charset]) → 返回文本;
+// - 只允许访问缓存目录内的文件。
+
+func newFileTestRunner(cacheDir, baseURL string, hits *int32) *JSRunner {
+ return NewJSRunner(JSConfig{
+ CacheDir: cacheDir,
+ BaseURL: baseURL,
+ Fetch: func(req *Request) (string, string, int, error) {
+ if hits != nil {
+ atomic.AddInt32(hits, 1)
+ }
+ resp, err := http.Get(req.URL) //nolint:gosec // 测试内固定 httptest 地址
+ if err != nil {
+ return "", "", 0, err
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return string(b), req.URL, resp.StatusCode, nil
+ },
+ })
+}
+
+func TestCacheFileReturnsTextContent(t *testing.T) {
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("第一章 开始\n第二章 继续"))
+ }))
+ defer srv.Close()
+
+ r := newFileTestRunner(t.TempDir(), srv.URL, &hits)
+ ar := NewAnalyzeRule()
+ v, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, "")
+ if err != nil {
+ t.Fatalf("cacheFile 失败: %v", err)
+ }
+ if got := anyToString(v); got != "第一章 开始\n第二章 继续" {
+ t.Fatalf("cacheFile 应返回文本内容,实际 %q", got)
+ }
+ // 第二次应命中缓存,不再发起请求。
+ if _, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ if n := atomic.LoadInt32(&hits); n != 1 {
+ t.Fatalf("重复调用应命中缓存(请求次数=%d)", n)
+ }
+}
+
+func TestCacheFileDecodesGBK(t *testing.T) {
+ gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("中文内容测试"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(gbk)
+ }))
+ defer srv.Close()
+
+ r := newFileTestRunner(t.TempDir(), srv.URL, nil)
+ v, err := r.Run(NewAnalyzeRule(), "java.cacheFile('"+srv.URL+"/gbk.txt')", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := anyToString(v); got != "中文内容测试" {
+ t.Fatalf("GBK 文本未正确解码: %q", got)
+ }
+}
+
+func TestDownloadFileThenReadTxtFile(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("正文内容"))
+ }))
+ defer srv.Close()
+
+ cacheDir := t.TempDir()
+ r := newFileTestRunner(cacheDir, srv.URL, nil)
+ ar := NewAnalyzeRule()
+
+ rawPath, err := r.Run(ar, "java.downloadFile('"+srv.URL+"/a.txt')", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rel := anyToString(rawPath)
+ if !strings.HasPrefix(rel, "/") || !strings.HasSuffix(rel, ".txt") {
+ t.Fatalf("downloadFile 应返回相对缓存路径,实际 %q", rel)
+ }
+ if _, err := os.Stat(filepath.Join(cacheDir, "reader", "files", strings.TrimPrefix(rel, "/"))); err != nil {
+ t.Fatalf("文件未落到缓存目录: %v", err)
+ }
+
+ v, err := r.Run(ar, "java.readTxtFile('"+rel+"')", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := anyToString(v); got != "正文内容" {
+ t.Fatalf("readTxtFile = %q", got)
+ }
+
+ del, err := r.Run(ar, "String(java.deleteFile('"+rel+"'))", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if anyToString(del) != "true" {
+ t.Fatalf("deleteFile 应返回 true,实际 %q", anyToString(del))
+ }
+}
+
+func TestFileOpsRejectPathTraversal(t *testing.T) {
+ cacheDir := t.TempDir()
+ secret := filepath.Join(cacheDir, "secret.txt")
+ if err := os.WriteFile(secret, []byte("不该被读到"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r := newFileTestRunner(cacheDir, "", nil)
+ ar := NewAnalyzeRule()
+
+ for _, p := range []string{"/../../secret.txt", "../secret.txt", "/../reader/files/../../secret.txt"} {
+ v, err := r.Run(ar, "java.readTxtFile('"+p+"')", nil, "")
+ if err != nil {
+ t.Fatalf("%s 不应报错: %v", p, err)
+ }
+ if got := anyToString(v); strings.Contains(got, "不该被读到") {
+ t.Fatalf("越权读到了缓存目录外的文件(%s)", p)
+ }
+ }
+}
+
+func TestFileOpsWithoutCacheDirFail(t *testing.T) {
+ r := NewJSRunner(JSConfig{})
+ _, err := r.Run(NewAnalyzeRule(), "java.cacheFile('https://example.com/a.txt')", nil, "")
+ if err == nil || !strings.Contains(err.Error(), "缓存目录") {
+ t.Fatalf("未配置缓存目录时应明确报错,实际 %v", err)
+ }
+}
diff --git a/internal/service/reader/rule/goja.go b/internal/service/reader/rule/goja.go
index 4395d18..675a59b 100644
--- a/internal/service/reader/rule/goja.go
+++ b/internal/service/reader/rule/goja.go
@@ -72,6 +72,9 @@ type JSConfig struct {
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
// nil 时这两个函数抛出不支持错误。
Browser BrowserHost
+ // CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
+ // 为空时这些函数抛出明确错误。
+ CacheDir string
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
Ctx context.Context
}
@@ -211,11 +214,100 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
vm.Set("source", srcObj)
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
r.installJava(nil)
+ // Packages(Java 类命名空间)同样要在 jsLib 之前注入:不少书源在 jsLib 或
+ // 目录/正文规则里用 `new Packages.java.util.LinkedHashMap()` 这类写法。
+ r.installPackages()
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
r.loadJSLib()
return r
}
+// installPackages 注入 Java 风格类命名空间(Packages.java.util.*)。
+//
+// goja 是纯 JS 运行时,没有 Java。书源(尤其照搬 Java 教程写的)常用
+// `new Packages.java.util.ArrayList()` / `LinkedHashMap` / `HashMap` 来拼
+// 目录列表和请求头,缺了它整条规则直接 ReferenceError(拷贝漫画轻小说源的
+// 目录规则就是这么写的)。
+//
+// 实现要点:容器用「真正的 JS 数组 / 对象」,方法用 Object.defineProperty 定义为
+// 不可枚举属性。这样容器交给引擎侧(GetElements/GetString)时导出的是干净的
+// 数组([]any)或映射(map[string]any),而不会把 add/put 这些方法也当成元素或
+// 请求头带出去。
+func (r *JSRunner) installPackages() {
+ if _, err := r.vm.RunString(packagesPreamble); err != nil {
+ // 注入失败不影响其它规则,只是 Packages.* 不可用。
+ r.jsLibErr = fmt.Errorf("Packages 环境初始化失败: %w", err)
+ }
+}
+
+// packagesPreamble 定义全局 Packages(以及与之等价的 java.util / java.lang 常用类)。
+const packagesPreamble = `
+var Packages = (function () {
+ function def(o, k, v) {
+ Object.defineProperty(o, k, { value: v, enumerable: false, writable: true, configurable: true });
+ }
+ function makeMap() {
+ var m = {};
+ def(m, 'put', function (k, v) { m[k] = v; return v; });
+ def(m, 'putAll', function (o) { if (o) { for (var k in o) { m[k] = o[k]; } } });
+ def(m, 'get', function (k) { return m[k] === undefined ? null : m[k]; });
+ def(m, 'getOrDefault', function (k, d) { return m[k] === undefined ? d : m[k]; });
+ def(m, 'containsKey', function (k) { return Object.prototype.hasOwnProperty.call(m, k); });
+ def(m, 'containsValue', function (v) { for (var k in m) { if (m[k] === v) { return true; } } return false; });
+ def(m, 'remove', function (k) { var v = m[k]; delete m[k]; return v; });
+ def(m, 'size', function () { return Object.keys(m).length; });
+ def(m, 'isEmpty', function () { return Object.keys(m).length === 0; });
+ def(m, 'clear', function () { for (var k in m) { delete m[k]; } });
+ def(m, 'keySet', function () { return Object.keys(m); });
+ def(m, 'values', function () { var a = []; for (var k in m) { a.push(m[k]); } return a; });
+ def(m, 'entrySet', function () { var a = []; for (var k in m) { a.push({ key: k, value: m[k] }); } return a; });
+ def(m, 'toString', function () { return JSON.stringify(m); });
+ return m;
+ }
+ function makeList() {
+ var a = [];
+ def(a, 'add', function (x) { a.push(x); return true; });
+ def(a, 'addAll', function (xs) { if (xs) { for (var i = 0; i < xs.length; i++) { a.push(xs[i]); } } return true; });
+ def(a, 'get', function (i) { return a[i]; });
+ def(a, 'size', function () { return a.length; });
+ def(a, 'isEmpty', function () { return a.length === 0; });
+ def(a, 'contains', function (x) { return a.indexOf(x) >= 0; });
+ def(a, 'remove', function (i) { return a.splice(i, 1)[0]; });
+ def(a, 'clear', function () { a.length = 0; });
+ def(a, 'toArray', function () { return a.slice(); });
+ def(a, 'toString', function () { return a.join(','); });
+ return a;
+ }
+ var util = {};
+ var mapNames = ['LinkedHashMap', 'HashMap', 'TreeMap', 'Hashtable', 'ConcurrentHashMap', 'LinkedTreeMap'];
+ for (var i = 0; i < mapNames.length; i++) { util[mapNames[i]] = makeMap; }
+ var listNames = ['ArrayList', 'LinkedList', 'Vector'];
+ for (var j = 0; j < listNames.length; j++) { util[listNames[j]] = makeList; }
+ util.HashSet = makeList;
+ util.Arrays = { asList: function () { return Array.prototype.slice.call(arguments); } };
+ util.Collections = {
+ emptyList: function () { return []; },
+ emptyMap: function () { return makeMap(); },
+ singletonList: function (x) { return [x]; }
+ };
+ var lang = {
+ String: function (v) { return v == null ? '' : String(v); },
+ Integer: function (v) { return parseInt(v, 10) || 0; },
+ Long: function (v) { return parseInt(v, 10) || 0; },
+ Double: function (v) { return parseFloat(v) || 0; },
+ Boolean: function (v) { return !!v; },
+ StringBuilder: function () {
+ var s = '';
+ var o = {};
+ def(o, 'append', function (x) { s += (x == null ? '' : x); return o; });
+ def(o, 'toString', function () { return s; });
+ return o;
+ }
+ };
+ return { java: { util: util, lang: lang } };
+})();
+`
+
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
func (r *JSRunner) installJava(a *AnalyzeRule) {
r.vm.Set("java", newJavaObject(r.vm, r, a))
@@ -399,7 +491,7 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
}
vm.Set("nextChapterUrl", nil)
- prog, err := compileCached(scopedRuleJS(js))
+ prog, err := compileRuleJS(js)
if err != nil {
return nil, fmt.Errorf("JS 编译失败: %w", err)
}
@@ -441,6 +533,31 @@ func scopedRuleJS(js string) string {
return "{\n" + js + "\n}"
}
+// functionRuleJS 把规则 JS 包成函数体后求值。
+//
+// legado 允许书源规则用顶层 `return` 提前返回(拷贝漫画轻小说源的正文规则
+// 就在 if 分支里 `return '
'`),而 JS 脚本语法不允许顶层 return,
+// 只有函数体允许。这里作为块形式的兜底。
+func functionRuleJS(js string) string {
+ return "(function(){\n" + js + "\n})()"
+}
+
+// compileRuleJS 编译一段书源规则 JS。
+//
+// 优先用「块」形式:块的完成值就是最后一条语句的值,大量书源依赖它
+// (如 `@js:1+2` 直接产出 3),且块作用域能隔离顶层 let/const(见 scopedRuleJS)。
+// 块形式编译失败时退回「函数体」形式,兼容 legado 允许的顶层 return。
+func compileRuleJS(js string) (*goja.Program, error) {
+ prog, err := compileCached(scopedRuleJS(js))
+ if err == nil {
+ return prog, nil
+ }
+ if prog2, err2 := compileCached(functionRuleJS(js)); err2 == nil {
+ return prog2, nil
+ }
+ return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
+}
+
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
func exportValue(v goja.Value) any {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
@@ -495,6 +612,9 @@ func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string,
}
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
mustSet("bodyStr", body)
+ // bodyAsBytes:部分书源用 `java.bytesToStr(resp.bodyAsBytes(), enc)` 处理
+ // 非 UTF-8(GBK)正文(拷贝漫画轻小说源即如此)。
+ mustSet("bodyAsBytes", func(call goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer([]byte(body))) })
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
mustSet("header", func(call goja.FunctionCall) goja.Value {
diff --git a/internal/service/reader/rule/js_test.go b/internal/service/reader/rule/js_test.go
index 241db85..a82f290 100644
--- a/internal/service/reader/rule/js_test.go
+++ b/internal/service/reader/rule/js_test.go
@@ -332,14 +332,51 @@ func TestJSUnsupportedStillWorks(t *testing.T) {
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
+// TestJSSandboxUnsupported 服务端不接受书源读取宿主任意文件。
+//
+// 文件类接口(cacheFile/downloadFile/readTxtFile/...)已实现,但路径被限制在
+// 书源自己的缓存目录内:越权路径安全地返回空,而不是把 /etc/passwd 交出去,
+// 也不应因为越权路径抛异常(否则书源会误判为「线路故障」反复重试)。
+// 真正无实现的能力(解压包、无头浏览器等)继续明确抛「不支持」。
func TestJSSandboxUnsupported(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
- _, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
- if err == nil || !strings.Contains(err.Error(), "不支持") {
- t.Fatalf("expected sandbox error, got %v", err)
+ got, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
+ if err != nil {
+ t.Fatalf("越权路径应安全返回空而不是抛异常: %v", err)
+ }
+ if strings.Contains(got, "root:") || strings.Contains(got, "/bin/") {
+ t.Fatalf("沙箱被突破,读到了宿主文件: %q", got)
+ }
+ for _, call := range []string{`java.unzipFile('/tmp/a.zip')`, `java.webView('x')`} {
+ if _, err := a.GetString(`@js:`+call, nil, false); err == nil || !strings.Contains(err.Error(), "不支持") {
+ t.Fatalf("%s 应继续抛「不支持」,实际 %v", call, err)
+ }
+ }
+}
+
+// TestRuleJSTopLevelReturn legado 允许规则 JS 顶层 return;无 return 时仍取最后
+// 一条语句的值(`@js:1+2` → 3)。
+func TestRuleJSTopLevelReturn(t *testing.T) {
+ r := NewJSRunner(JSConfig{})
+ ar := NewAnalyzeRule()
+
+ v, err := r.Run(ar, "if (1) { return '早退'; }\nreturn '兜底';", nil, "")
+ if err != nil {
+ t.Fatalf("顶层 return 应可用: %v", err)
+ }
+ if got := anyToString(v); got != "早退" {
+ t.Fatalf("top-level return = %q, want 早退", got)
+ }
+
+ v2, err := r.Run(ar, "1 + 2", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := anyToString(v2); got != "3" {
+ t.Fatalf("最后一条语句的值 = %q, want 3", got)
}
}
diff --git a/internal/service/reader/rule/json.go b/internal/service/reader/rule/json.go
index 02dcceb..065ac75 100644
--- a/internal/service/reader/rule/json.go
+++ b/internal/service/reader/rule/json.go
@@ -32,13 +32,53 @@ func jsonRead(root any, path string) any {
if root == nil || path == "" {
return nil
}
- v, err := jsonpathGet(path, root)
+ v, err := jsonpathGet(normalizeJSONPath(path), root)
if err != nil {
return nil
}
return v
}
+// normalizeJSONPath 把 legado(Jayway)允许、而 PaesslerAG/jsonpath 拒绝的写法
+// 归一化成合法路径,共两处差异:
+//
+// 1. 缺根 `$`:Jayway 要求路径以 `$`/`@` 开头,legado 书源却常直接写
+// `results.list`、`results.list.[*]`。PaesslerAG 对「不含 `[]`/`*` 的裸路径」
+// 恰好能容忍(内部按 `$.` 解析),但一旦出现 `[*]` 就报
+// `unexpected "*" while scanning extensions`。这里统一补根。
+// 2. 点后紧跟方括号:`results.list.[*]`、`$.a.[0]`、`$.a.['k']`。Jayway 里
+// `.[` 等价于 `[`,PaesslerAG 会报 `unexpected "[" while scanning field`。
+//
+// 两处都会让整条规则静默返回空——表现为「书源明明有效却搜不出任何结果」
+// (拷贝轻小说源的搜索列表规则就是 `results.list.[*]`)。
+func normalizeJSONPath(path string) string {
+ p := strings.TrimSpace(path)
+ if p == "" {
+ return path
+ }
+ switch {
+ case strings.HasPrefix(p, "$"), strings.HasPrefix(p, "@"):
+ // 已有根,保持
+ case strings.HasPrefix(p, "."):
+ p = "$" + p // `..a` 这类递归写法,补 `$` 而不是 `$.`
+ default:
+ p = "$." + p
+ }
+ if !strings.Contains(p, ".[") {
+ return p
+ }
+ var b strings.Builder
+ b.Grow(len(p))
+ for i := 0; i < len(p); i++ {
+ // 丢掉 `[` 前多余的点;递归下降 `..[` 保持原样。
+ if p[i] == '.' && i+1 < len(p) && p[i+1] == '[' && i > 0 && p[i-1] != '.' {
+ continue
+ }
+ b.WriteByte(p[i])
+ }
+ return b.String()
+}
+
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
diff --git a/internal/service/reader/rule/packages_test.go b/internal/service/reader/rule/packages_test.go
new file mode 100644
index 0000000..2a6467b
--- /dev/null
+++ b/internal/service/reader/rule/packages_test.go
@@ -0,0 +1,110 @@
+package rule
+
+import (
+ "testing"
+)
+
+// 本文件:legado 兼容性回归——`Packages.java.util.*` 容器与 `.[*]` 形式的 JSONPath。
+//
+// 背景(拷贝系列轻小说源在 MeBox 搜不到/读不了的根因):
+// 1. 搜索列表规则写成 `results.list.[*]`(legado/Jayway 接受),PaesslerAG 直接
+// 解析报错返回空;
+// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()` / `ArrayList()` 拼装,
+// goja 里没有 Java,整条规则 ReferenceError。
+
+// TestNormalizeJSONPathDotBracket 点后跟方括号应归一化成合法路径。
+func TestNormalizeJSONPathDotBracket(t *testing.T) {
+ cases := map[string]string{
+ "results.list.[*]": "$.results.list[*]",
+ "$.results.list.[*]": "$.results.list[*]",
+ "$.a.[0].b": "$.a[0].b",
+ "$.a.['k']": "$.a['k']",
+ "$.results.list": "$.results.list",
+ "$.results..list[*]": "$.results..list[*]",
+ "results.list": "$.results.list",
+ "$[0].name": "$[0].name",
+ "..list[*]": "$..list[*]",
+ }
+ for in, want := range cases {
+ if got := normalizeJSONPath(in); got != want {
+ t.Errorf("normalizeJSONPath(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// TestGetElementsDotBracketStarJsonPath 真实书源的 `results.list.[*]` 必须能取到列表。
+func TestGetElementsDotBracketStarJsonPath(t *testing.T) {
+ const body = `{"results":{"list":[{"name":"甲"},{"name":"乙"}]}}`
+ for _, rule := range []string{"results.list.[*]", "$.results.list", "results.list"} {
+ ar := NewAnalyzeRule()
+ ar.SetContent(body, "")
+ els, err := ar.GetElements(rule)
+ if err != nil {
+ t.Fatalf("%s: %v", rule, err)
+ }
+ if len(els) != 2 {
+ t.Fatalf("%s: elements=%d, want 2", rule, len(els))
+ }
+ name, err := ar.GetString("$.name", els[0], false)
+ if err != nil || name != "甲" {
+ t.Fatalf("%s: first name=%q err=%v", rule, name, err)
+ }
+ }
+}
+
+// TestPackagesUtilCollections Packages.java.util 的 List/Map 应能拼出目录列表,
+// 且导出给引擎的是干净的数组/映射(方法不能混进元素里)。
+func TestPackagesUtilCollections(t *testing.T) {
+ r := NewJSRunner(JSConfig{})
+ if err := r.JSLibErr(); err != nil {
+ t.Fatalf("Packages 环境初始化失败: %v", err)
+ }
+ ar := NewAnalyzeRule()
+ ar.SetContent(`{"results":{"list":[{"name":"第一卷","id":7}]}}`, "https://api.example.com/volumes")
+ ar.SetJSRunner(r.ForAnalyzer(ar))
+
+ js := `
+function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
+var base = 'https://api.example.com/volume/';
+var out = new Packages.java.util.ArrayList();
+var vols = JSON.parse(result).results.list;
+for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id)); }
+out;`
+ els, err := ar.GetElements(js)
+ if err != nil {
+ t.Fatalf("GetElements: %v", err)
+ }
+ if len(els) != 1 {
+ t.Fatalf("elements=%d, want 1", len(els))
+ }
+ if m, ok := els[0].(map[string]any); ok {
+ if _, hasMethod := m["put"]; hasMethod {
+ t.Fatalf("容器方法泄漏进了元素: %#v", m)
+ }
+ }
+ name, err := ar.GetString("$.name", els[0], false)
+ if err != nil || name != "第一卷" {
+ t.Fatalf("name=%q err=%v", name, err)
+ }
+ url, _ := ar.GetString("$.url", els[0], false)
+ if url != "https://api.example.com/volume/7" {
+ t.Fatalf("url=%q", url)
+ }
+}
+
+// TestPackagesHashMapUsableAsHeaders java.get(url, headers) 的 headers 用
+// Packages.java.util.HashMap 构造时,导出结果应是干净的键值映射。
+func TestPackagesHashMapUsableAsHeaders(t *testing.T) {
+ r := NewJSRunner(JSConfig{})
+ v, err := r.Run(NewAnalyzeRule(), `(function () {
+ var h = new Packages.java.util.HashMap();
+ h.put('X-Test', 'v1');
+ return JSON.stringify(h);
+})()`, nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s := anyToString(v); s != `{"X-Test":"v1"}` {
+ t.Fatalf("HashMap 导出含额外键: %s", s)
+ }
+}
diff --git a/internal/service/reader/rule/url.go b/internal/service/reader/rule/url.go
index bd93af3..4fcd3d6 100644
--- a/internal/service/reader/rule/url.go
+++ b/internal/service/reader/rule/url.go
@@ -43,6 +43,10 @@ type Request struct {
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
HexBody bool
+ // Raw 请求响应按「原始字节」返回,跳过 charset 解码。
+ // 供 java.downloadFile / java.cacheFile 这类需要保真落盘的调用使用:
+ // 一旦按 UTF-8 解码,GBK 字节会被替换成 U+FFFD,写出来的文件就坏了。
+ Raw bool
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
BodyJsFn func(body string) string
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
diff --git a/vol.json b/vol.json
new file mode 100644
index 0000000..6c851c3
--- /dev/null
+++ b/vol.json
@@ -0,0 +1 @@
+{"code":200,"message":"请求成功","results":{"is_lock":false,"is_login":false,"is_mobile_bind":false,"is_vip":false,"book":{"name":"我被告白之後,大小姐的樣子有點怪","uuid":"423125f4-2a5c-11ef-91d4-3f487b7d9a9a","path_word":"wobeigaobaizhihoudaxiaojiedeyangziyoudianguai"},"volume":{"index":0,"id":"13659","count":1,"sort":10,"name":"第01卷","txt_addr":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/17183766221442089.txt","txt_encoding":"GBK","book_id":"423125f4-2a5c-11ef-91d4-3f487b7d9a9a","book_path_word":"wobeigaobaizhihoudaxiaojiedeyangziyoudianguai","contents":[{"name":" 第一卷 序章 故事是这样的 ","content_type":1,"content":null,"start_lines":2,"end_lines":355},{"name":" 第一卷 第一章 大小姐,加油鼓劲 ","content_type":1,"content":null,"start_lines":355,"end_lines":846},{"name":" 第一卷 第二章 大小姐的暗中活跃 ","content_type":1,"content":null,"start_lines":846,"end_lines":1597},{"name":" 第一卷 第三章 新的威胁 ","content_type":1,"content":null,"start_lines":1597,"end_lines":2480},{"name":" 第一卷 第四章 大小姐与歌姬 ","content_type":1,"content":null,"start_lines":2480,"end_lines":3925},{"name":" 第一卷 第五章 决战 ","content_type":1,"content":null,"start_lines":3925,"end_lines":5916},{"name":" 第一卷 终章 决意 ","content_type":1,"content":null,"start_lines":5916,"end_lines":6095},{"name":"插图01","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768421716386.jpg","start_lines":0,"end_lines":0},{"name":"插图02","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768421529202.jpg","start_lines":0,"end_lines":0},{"name":"插图03","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768421722176.jpg","start_lines":0,"end_lines":0},{"name":"插图04","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768444783165.jpg","start_lines":0,"end_lines":0},{"name":"插图05","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768421167560.jpg","start_lines":0,"end_lines":0},{"name":"插图06","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768511431244.jpg","start_lines":0,"end_lines":0},{"name":"插图07","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768521152403.jpg","start_lines":0,"end_lines":0},{"name":"插图08","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768594601066.jpg","start_lines":0,"end_lines":0},{"name":"插图09","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768605564736.jpg","start_lines":0,"end_lines":0},{"name":"插图10","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768643491355.jpg","start_lines":0,"end_lines":0},{"name":"插图11","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768677795353.jpg","start_lines":0,"end_lines":0},{"name":"插图12","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768694907891.jpg","start_lines":0,"end_lines":0},{"name":"插图13","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768783869454.jpg","start_lines":0,"end_lines":0},{"name":"插图14","content_type":2,"content":"https://s3.mangafunb.fun/book/wobeigaobaizhihoudaxiaojiedeyangziyoudianguai/imgs/17183768804339556.jpg","start_lines":0,"end_lines":0}],"prev":null,"next":null}}}
\ No newline at end of file