From 2b2dc697cea195c4f00330adb9c6b5d72997e678 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Thu, 8 Oct 2026 11:13:10 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/reader-smoke/main.go | 11 +- internal/helper/http.go | 11 +- internal/helper/http_test.go | 12 + internal/service/reader/reader.go | 26 +++ .../service/reader/reader_copy_source_test.go | 121 ++++++++++ internal/service/reader/rule/bridge.go | 97 +++++++- internal/service/reader/rule/bridge_files.go | 209 ++++++++++++++++++ internal/service/reader/rule/files_test.go | 152 +++++++++++++ internal/service/reader/rule/goja.go | 122 +++++++++- internal/service/reader/rule/js_test.go | 43 +++- internal/service/reader/rule/json.go | 42 +++- internal/service/reader/rule/packages_test.go | 110 +++++++++ internal/service/reader/rule/url.go | 4 + vol.json | 1 + 14 files changed, 952 insertions(+), 9 deletions(-) create mode 100644 internal/service/reader/reader_copy_source_test.go create mode 100644 internal/service/reader/rule/bridge_files.go create mode 100644 internal/service/reader/rule/files_test.go create mode 100644 internal/service/reader/rule/packages_test.go create mode 100644 vol.json 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