diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..232bbe8 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,26 @@ + + +
,至少让用户看到内容 + wrapped := "" + htmlEscape(req.Title) + + "" + htmlEscape(body) + "" + return injectBrowserBridge(wrapped, finalURL, cookie), finalURL, nil + } + return injectBrowserBridge(s.rewriteBrowserHTML(body, finalURL, id), finalURL, cookie), finalURL, nil +} + +// browserCookieHeader 取书源在目标站点上的 Cookie 串(页面里预置 document.cookie 用)。 +func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, target string) string { + if s.repo == nil || sourceURL == "" { + return "" + } + return s.newSourceState(ctx, sourceURL).CookieForRequest(target) +} + +// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。 +func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) { + contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target) + if err != nil { + return "", "", "", err + } + return string(data), finalURL, contentType, nil +} + +// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。 +// 返回 (contentType, status, body, error)。 +func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) { + contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target) + return contentType, status, data, err +} + +// requestBrowserResource 带书源凭据请求一个外部地址。 +func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) { + reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout) + defer cancel() + + state := s.newSourceState(reqCtx, sourceURL) + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil) + if err != nil { + return "", 0, nil, "", err + } + for k, v := range state.LoginHeaderMap() { + if strings.EqualFold(k, "cookie") { + continue + } + httpReq.Header.Set(k, v) + } + if ck := state.CookieForRequest(target); ck != "" { + httpReq.Header.Set("Cookie", ck) + } + // 书源级请求头(含 Referer / UA),与阅读请求保持一致 + if s.repo != nil { + if src, findErr := s.repo.GetSourceByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" { + var headers map[string]any + if json.Unmarshal([]byte(src.Header), &headers) == nil { + for k, v := range headers { + if httpReq.Header.Get(k) == "" { + httpReq.Header.Set(k, fmt.Sprintf("%v", v)) + } + } + } + } + } + // Accept-Encoding 交给 net/http 管:显式设置会让它放弃自动解压, + // 压缩过的页面/资源就会以原始字节回到 iframe(页面直接白屏或乱码)。 + helper.StripAcceptEncoding(httpReq.Header) + resp, err := s.http.Do(httpReq) + if err != nil { + return "", 0, nil, "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, browserMaxPageBytes)) + if err != nil { + return "", 0, nil, "", err + } + data = helper.DecompressBody(resp, data) + finalURL := target + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + return contentType, resp.StatusCode, data, finalURL, nil +} + +// BrowserXHRResult 页面内 fetch/XHR 经服务端转发后的响应。 +type BrowserXHRResult struct { + Status int `json:"status"` + ContentType string `json:"content_type"` + Body string `json:"body"` + // Base64 为真时 Body 是 base64(二进制资源)。 + Base64 bool `json:"base64"` +} + +// browserXHRMaxBytes 转发接口响应的大小上限。 +const browserXHRMaxBytes = 4 << 20 + +// ProxyBrowserXHR 以书源身份转发页面内的接口请求。 +// +// 承载页面是不透明源,页面自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦掉, +// 所以由父窗口把请求转交进来,这里补上书源凭据再发出去。 +// 返回的 HTTP 状态/内容类型原样回给页面,让页面自己的逻辑能正常分支。 +func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target string, headers map[string]string, body string) (*BrowserXHRResult, error) { + entry := s.lookupBrowser(id) + if entry == nil { + return nil, errors.New("页面已过期,请重新打开") + } + if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + return nil, fmt.Errorf("仅支持 http(s) 地址: %s", truncateForLog(target, 120)) + } + if method == "" { + method = http.MethodGet + } + reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body)) + if err != nil { + return nil, err + } + // 先放页面自己声明的头,再用书源凭据补缺(Cookie 始终以服务端为准) + for k, v := range headers { + if strings.EqualFold(k, "cookie") || strings.EqualFold(k, "host") || + strings.EqualFold(k, "content-length") { + continue + } + httpReq.Header.Set(k, v) + } + state := s.newSourceState(reqCtx, entry.sourceURL) + for k, v := range state.LoginHeaderMap() { + if strings.EqualFold(k, "cookie") { + continue + } + if httpReq.Header.Get(k) == "" { + httpReq.Header.Set(k, v) + } + } + if httpReq.Header.Get("Cookie") == "" { + if ck := state.CookieForRequest(target); ck != "" { + httpReq.Header.Set("Cookie", ck) + } + } + if s.repo != nil { + if src, findErr := s.repo.GetSourceByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" { + var extra map[string]any + if json.Unmarshal([]byte(src.Header), &extra) == nil { + for k, v := range extra { + if httpReq.Header.Get(k) == "" { + httpReq.Header.Set(k, fmt.Sprintf("%v", v)) + } + } + } + } + } + helper.StripAcceptEncoding(httpReq.Header) + resp, err := s.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, browserXHRMaxBytes)) + if err != nil { + return nil, err + } + data = helper.DecompressBody(resp, data) + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/plain" + } + out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType} + if isTextualContent(contentType) { + out.Body = string(data) + } else { + out.Body = base64.StdEncoding.EncodeToString(data) + out.Base64 = true + } + return out, nil +} + +// isTextualContent 判断响应是否可以直接当字符串交给页面。 +func isTextualContent(contentType string) bool { + ct := strings.ToLower(contentType) + for _, p := range []string{"text/", "json", "javascript", "xml", "html", "x-www-form-urlencoded", "csv"} { + if strings.Contains(ct, p) { + return true + } + } + return false +} + +// ─── 资源改写与代理 ──────────────────────────────────────────────────────── + +// cssURLRe 匹配 CSS 里的 url(...) 引用。 +// Go 的 regexp 不支持反向引用,因此引号用可选的成对字符类近似匹配。 +var cssURLRe = regexp.MustCompile(`url\(\s*['"]?([^'")]+)['"]?\s*\)`) + +// rewriteBrowserHTML 把页面里的资源/表单/站内链接地址改写到同源代理, +// 使页面在 iframe 里的后续请求同样带上书源 Cookie。 +func (s *ReaderService) rewriteBrowserHTML(body, baseURL, id string) string { + base, err := url.Parse(baseURL) + if err != nil { + return body + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + return body + } + rewrite := func(sel, attr string) { + doc.Find(sel).Each(func(_ int, node *goquery.Selection) { + val, ok := node.Attr(attr) + if !ok || strings.TrimSpace(val) == "" { + return + } + if abs := absoluteBrowserURL(base, val); abs != "" { + node.SetAttr(attr, s.browserAssetProxyURL(id, abs)) + } + }) + } + rewrite("img[src]", "src") + rewrite("script[src]", "src") + rewrite("link[href]", "href") + rewrite("iframe[src]", "src") + rewrite("source[src]", "src") + rewrite("video[src]", "src") + rewrite("audio[src]", "src") + // 表单提交也走代理,避免 POST 丢掉 Cookie + rewrite("form[action]", "action") + // 站内链接走代理;站外链接保留(用户可能确实想出去) + doc.Find("a[href]").Each(func(_ int, node *goquery.Selection) { + val, ok := node.Attr("href") + if !ok { + return + } + abs := absoluteBrowserURL(base, val) + if abs == "" || !sameSite(abs, baseURL) { + return + } + node.SetAttr("href", s.browserAssetProxyURL(id, abs)) + }) + // 内联样式里的 url(...) + doc.Find("[style]").Each(func(_ int, node *goquery.Selection) { + style, ok := node.Attr("style") + if !ok || !strings.Contains(style, "url(") { + return + } + node.SetAttr("style", rewriteCSSURLs(style, base, func(abs string) string { + return s.browserAssetProxyURL(id, abs) + })) + }) + out, err := doc.Html() + if err != nil { + return body + } + return "" + out +} + +// RewriteBrowserCSS 改写 CSS 文本里的 url(...) 引用(资源代理用)。 +func (s *ReaderService) RewriteBrowserCSS(css, baseURL, id string) string { + base, err := url.Parse(baseURL) + if err != nil { + return css + } + return rewriteCSSURLs(css, base, func(abs string) string { + return s.browserAssetProxyURL(id, abs) + }) +} + +func rewriteCSSURLs(css string, base *url.URL, proxy func(abs string) string) string { + return cssURLRe.ReplaceAllStringFunc(css, func(m string) string { + sub := cssURLRe.FindStringSubmatch(m) + if len(sub) < 2 { + return m + } + abs := absoluteBrowserURL(base, sub[1]) + if abs == "" { + return m + } + return `url("` + proxy(abs) + `")` + }) +} + +// absoluteBrowserURL 把页面里的相对地址解析成绝对地址;不可代理的协议返回空串。 +func absoluteBrowserURL(base *url.URL, ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" || strings.HasPrefix(ref, "#") { + return "" + } + lower := strings.ToLower(ref) + for _, p := range []string{"data:", "javascript:", "mailto:", "tel:", "blob:", "about:", "ws:", "wss:"} { + if strings.HasPrefix(lower, p) { + return "" + } + } + parsed, err := url.Parse(ref) + if err != nil { + return "" + } + abs := base.ResolveReference(parsed).String() + if !strings.HasPrefix(abs, "http://") && !strings.HasPrefix(abs, "https://") { + return "" + } + return abs +} + +// sameSite 判断两个地址是否同站(eTLD+1)。 +func sameSite(a, b string) bool { + return rule.CookieDomain(a) == rule.CookieDomain(b) +} + +// ─── 签名与地址 ──────────────────────────────────────────────────────────── + +// browserSign 用 JWT 密钥做 HMAC,绑定待办 ID(页面)或 ID+目标地址(资源)。 +func (s *ReaderService) browserSign(payload string) string { + mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret)) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil))[:32] +} + +func (s *ReaderService) browserPageSig(id string) string { return s.browserSign("page|" + id) } + +func (s *ReaderService) browserAssetSig(id, target string) string { + return s.browserSign("asset|" + id + "|" + target) +} + +func (s *ReaderService) browserPageURL(id string) string { + return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id) +} + +// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。 +func (s *ReaderService) browserAssetProxyURL(id, target string) string { + return "/api/reader/browser/asset?id=" + url.QueryEscape(id) + + "&s=" + s.browserAssetSig(id, target) + + "&u=" + base64.RawURLEncoding.EncodeToString([]byte(target)) +} + +// VerifyBrowserPage 校验页面承载签名,返回待办快照。 +func (s *ReaderService) VerifyBrowserPage(id, sig string) (*BrowserPageSnapshot, error) { + if !hmac.Equal([]byte(s.browserPageSig(id)), []byte(sig)) { + return nil, errors.New("页面地址签名校验失败") + } + entry := s.lookupBrowser(id) + if entry == nil { + return nil, errors.New("页面已过期,请重新打开") + } + return &BrowserPageSnapshot{ + ID: entry.id, + HTML: entry.html, + SourceID: entry.sourceID, + FinalURL: entry.finalURL, + }, nil +} + +// VerifyBrowserAsset 校验资源代理签名并还原目标地址,返回(目标地址, 书源 URL)。 +func (s *ReaderService) VerifyBrowserAsset(id, encoded, sig string) (string, string, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", "", errors.New("资源地址解码失败") + } + target := string(raw) + if !hmac.Equal([]byte(s.browserAssetSig(id, target)), []byte(sig)) { + return "", "", errors.New("资源地址签名校验失败") + } + entry := s.lookupBrowser(id) + if entry == nil { + return "", "", errors.New("页面已过期,请重新打开") + } + return target, entry.sourceURL, nil +} + +// ─── 待办表 ──────────────────────────────────────────────────────────────── + +// PendingBrowserPages 返回该用户在某书源下待用户完成的页面(前端轮询)。 +// 含 open 模式:startBrowser 只展示不回传,同样需要前端把页面呈现出来。 +func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPage { + s.browserMu.Lock() + defer s.browserMu.Unlock() + s.pruneBrowsersLocked() + out := make([]BrowserPage, 0, len(s.browserPending)) + for _, e := range s.browserPending { + if e.sourceID != sourceID { + continue + } + // 待办是短生命周期对象;未标注用户的老调用路径一律放行。 + if e.userID != "" && userID != "" && e.userID != userID { + continue + } + out = append(out, s.pageOfLocked(e)) + } + return out +} + +// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。 +func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error { + entry := s.lookupBrowser(id) + if entry == nil { + return errors.New("页面已过期或已完成") + } + entry.mu.Lock() + defer entry.mu.Unlock() + if entry.finished { + return errors.New("该页面已完成") + } + if cancelled { + entry.err = rule.ErrBrowserCancelled + } else { + resultURL := strings.TrimSpace(finalURL) + if resultURL == "" { + resultURL = entry.finalURL + } + entry.result = rule.BrowserResult{URL: resultURL, Body: body} + } + entry.finished = true + if entry.done != nil { + close(entry.done) + } + return nil +} + +func (s *ReaderService) lookupBrowser(id string) *pendingBrowser { + s.browserMu.Lock() + defer s.browserMu.Unlock() + s.pruneBrowsersLocked() + return s.browserPending[id] +} + +func (s *ReaderService) dropBrowser(id string) { + s.browserMu.Lock() + defer s.browserMu.Unlock() + delete(s.browserPending, id) +} + +// pruneBrowsersLocked 清掉过期条目并唤醒仍在等待的调用方(避免泄漏协程)。 +func (s *ReaderService) pruneBrowsersLocked() { + now := time.Now() + for id, e := range s.browserPending { + if now.Before(e.expires) { + continue + } + e.mu.Lock() + if !e.finished { + e.finished = true + e.err = errors.New("页面等待超时") + if e.done != nil { + close(e.done) + } + } + e.mu.Unlock() + delete(s.browserPending, id) + } +} + +// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。 +func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage { + target := e.request.URL + if strings.HasPrefix(target, "data:") || len(target) > 512 { + target = "" + } + return BrowserPage{ + ID: e.id, + Title: e.request.Title, + Mode: e.mode, + PageURL: s.browserPageURL(e.id), + Refetch: e.request.Refetch, + SourceID: e.sourceID, + TargetURL: target, + } +} + +// ─── 小工具 ──────────────────────────────────────────────────────────────── + +func newBrowserID() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func htmlEscape(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s) +} + +func truncateForLog(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/service/reader/browser_panel_test.go b/internal/service/reader/browser_panel_test.go new file mode 100644 index 0000000..82d9194 --- /dev/null +++ b/internal/service/reader/browser_panel_test.go @@ -0,0 +1,392 @@ +package reader + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/truewhile/MeBox/internal/service/reader/rule" +) + +// 本文件:书源宿主浏览器面板的服务层测试(browser_panel.go)。 +// +// 覆盖真实书源依赖的三件事: +// 1. startBrowserAwait 登记待办 → 前端轮询到 → 回传 DOM → 阻塞解除; +// 2. http(s) 页面由服务端带书源 Cookie 抓取(浏览器里没有这些 Cookie); +// 3. 页面里的资源地址被改写到同源代理,且签名校验拦得住伪造。 + +// browserPanelSourceJSON 构造一个内联书源:登录面板有「切换线路」按钮, +// 其实现与光遇聚合的 getServerSettings 同构——打开内嵌 HTML,回传 DOM 后 +// 从 DOM 里解析线路写进源变量。 +func browserPanelSourceJSON(t *testing.T, sourceURL string) string { + t.Helper() + loginJS := `function switchLine() { + let html = '线路甲'; + let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body(); + let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/); + source.setVariable(JSON.stringify({线路: m ? m[1] : ''})); + return m ? m[1] : ''; +}` + src := map[string]any{ + "bookSourceUrl": sourceURL, + "bookSourceName": "浏览器面板测试源", + "loginUrl": loginJS, + "loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`, + } + out, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +// querySig 从签名地址里取出 s 参数。 +func querySig(t *testing.T, raw string) string { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("承载地址不合法: %q", raw) + } + return u.Query().Get("s") +} + +// waitPending 轮询等待出现一个待办页面。 +func waitPending(t *testing.T, svc *ReaderService, sourceID string) BrowserPage { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + pages := svc.PendingBrowserPages(readerTestUserID, sourceID) + if len(pages) > 0 { + return pages[0] + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("等待超时:未出现待用户完成的页面") + return BrowserPage{} +} + +// TestBrowserPanelSwitchLineRoundTrip 端到端:书源阻塞 → 前端拿到页面 → +// 回传 DOM → 书源把线路写进源变量。 +func TestBrowserPanelSwitchLineRoundTrip(t *testing.T) { + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com")) + + type actionResult struct { + res *LoginResult + err error + } + done := make(chan actionResult, 1) + go func() { + res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "switchLine()", nil) + done <- actionResult{res: res, err: err} + }() + + page := waitPending(t, svc, sourceID) + if page.Mode != browserModeWait { + t.Fatalf("模式 = %q,期望 wait", page.Mode) + } + if page.Title != "线路设置" { + t.Fatalf("标题 = %q", page.Title) + } + // data: 地址不应回传给前端展示(又长又没用) + if page.TargetURL != "" { + t.Fatalf("data: 地址不应回传: %q", page.TargetURL) + } + + // 承载页面的签名必须有效,且内容是书源拼的那段 HTML + snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL)) + if err != nil { + t.Fatalf("承载页面校验失败: %v", err) + } + if !strings.Contains(snap.HTML, `id="serverValue"`) { + t.Fatalf("承载页面内容异常: %q", snap.HTML) + } + // 伪造签名必须被拒 + if _, err := svc.VerifyBrowserPage(page.ID, "deadbeef"); err == nil { + t.Fatal("伪造签名不应通过校验") + } + + // 模拟用户在页面里改选了「线路乙」后点 √:回传操作后的 DOM + if err := svc.ResolveBrowser(page.ID, readerTestUserID, + `线路乙`, "", false); err != nil { + t.Fatalf("回传结果失败: %v", err) + } + + select { + case r := <-done: + if r.err != nil { + t.Fatalf("登录动作失败: %v", r.err) + } + if !r.res.OK { + t.Fatalf("动作未成功: %+v", r.res) + } + case <-time.After(5 * time.Second): + t.Fatal("回传后阻塞未解除") + } + + // 书源应已把线路写进源变量(真正切换生效) + st := svc.newSourceState(ctx, "https://panel.example.com") + if !strings.Contains(st.GetVariable(), "线路乙") { + t.Fatalf("线路未写入源变量: %q", st.GetVariable()) + } + // 待办应被清理 + if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 { + t.Fatalf("完成后待办未清理: %+v", pages) + } +} + +// TestBrowserPanelCancelReleasesBlock 取消页面时要解除阻塞(书源走降级分支)。 +func TestBrowserPanelCancelReleasesBlock(t *testing.T) { + svc, _ := newLoginTestService(t) + sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com")) + + done := make(chan error, 1) + go func() { + // action 为空表示执行 login();这里用 switchLine,取消后书源会抛异常 + _, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil) + done <- err + }() + + page := waitPending(t, svc, sourceID) + if err := svc.ResolveBrowser(page.ID, readerTestUserID, "", "", true); err != nil { + t.Fatalf("取消失败: %v", err) + } + select { + case err := <-done: + // 书源未 catch,异常应回到调用方;关键是「不能一直卡住」 + if err == nil { + t.Log("取消后动作用空 body 继续执行(书源自行降级)") + } + case <-time.After(5 * time.Second): + t.Fatal("取消后阻塞未解除") + } +} + +// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie +// 抓取,否则「用户后台」在浏览器里永远是未登录状态。 +func TestBrowserPanelInjectsSourceCookies(t *testing.T) { + var userCookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + userCookie = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if !strings.Contains(userCookie, "qttoken=") { + _, _ = w.Write([]byte(`请先登录`)) + return + } + _, _ = w.Write([]byte(`我的账号` + + `订单` + + `站外`)) + case "/avatar.png": + _, _ = w.Write([]byte("PNGDATA")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL)) + + // 先造出登录态(等价于书源已登录成功) + st := svc.newSourceState(ctx, srv.URL) + st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456") + st.flush() + + // 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台 + if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{ + URL: srv.URL + "/user", Title: "用户后台", + }); err != nil { + t.Fatalf("打开用户后台失败: %v", err) + } + if !strings.Contains(userCookie, "qttoken=SESSION_abcdef123456") { + t.Fatalf("抓取页面时未带上书源 Cookie: %q", userCookie) + } + + page := waitPending(t, svc, sourceID) + if page.Mode != browserModeOpen { + t.Fatalf("模式 = %q,期望 open", page.Mode) + } + if page.TargetURL != srv.URL+"/user" { + t.Fatalf("原始地址 = %q", page.TargetURL) + } + snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL)) + if err != nil { + t.Fatal(err) + } + // 已登录内容(证明用的是服务端 Cookie 抓到的页面) + if !strings.Contains(snap.HTML, "我的账号") { + t.Fatalf("页面不是登录态内容: %q", snap.HTML) + } + // 资源地址应被改写到同源代理;站外链接保留原样 + if !strings.Contains(snap.HTML, "/api/reader/browser/asset?") { + t.Fatalf("资源地址未改写: %q", snap.HTML) + } + if !strings.Contains(snap.HTML, "https://other-site.net/x") { + t.Fatalf("站外链接不应改写: %q", snap.HTML) + } + // 注入脚本要带齐三样:DOM 回传、请求代理、以及书源登录态 + if !strings.Contains(snap.HTML, "data-mebox-browser-bridge") { + t.Fatalf("缺少注入脚本: %q", snap.HTML) + } + if !strings.Contains(snap.HTML, "__mebox_proxy__") { + t.Fatal("注入脚本缺少请求代理(页面自己的 fetch/XHR 会因不透明源被 CORS 拦)") + } + // 页面常用 document.cookie 判断登录态;真实 Cookie 在服务端, + // 不预置的话「用户后台」会以为未登录并把浏览器导去 /login + if !strings.Contains(snap.HTML, "qttoken=SESSION_abcdef123456") { + t.Fatalf("注入脚本未预置书源 Cookie: %q", snap.HTML) + } + // 相对地址要按书源页面地址解析(沙箱里基地址不是承载地址) + if !strings.Contains(snap.HTML, srv.URL) { + t.Fatalf("注入脚本未带上页面基地址: %q", snap.HTML) + } + + // 资源代理要能校验签名并把 Cookie 带上 + assetURL := extractAssetURL(t, snap.HTML) + u, err := url.Parse(assetURL) + if err != nil { + t.Fatal(err) + } + target, gotSourceURL, err := svc.VerifyBrowserAsset( + u.Query().Get("id"), u.Query().Get("u"), u.Query().Get("s")) + if err != nil { + t.Fatalf("资源签名校验失败: %v", err) + } + if !strings.HasSuffix(target, "/avatar.png") { + t.Fatalf("资源目标 = %q", target) + } + if gotSourceURL != srv.URL { + t.Fatalf("资源关联的书源 = %q", gotSourceURL) + } + contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target) + if err != nil { + t.Fatalf("拉取资源失败: %v", err) + } + if status != http.StatusOK || string(data) != "PNGDATA" { + t.Fatalf("资源内容异常: status=%d body=%q", status, data) + } + if contentType == "" { + t.Fatal("资源缺少 Content-Type") + } + // 伪造资源签名必须被拒 + if _, _, err := svc.VerifyBrowserAsset(u.Query().Get("id"), u.Query().Get("u"), "deadbeef"); err == nil { + t.Fatal("伪造资源签名不应通过校验") + } +} + +// TestBrowserPanelXHRProxy 页面内的接口请求要由服务端代发并补上书源 Cookie。 +// 「用户后台」这类页面靠接口取数,iframe 自己的 XHR 带不上 Cookie 也会被 CORS 拦。 +func TestBrowserPanelXHRProxy(t *testing.T) { + var apiCookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`后台`)) + case "/api/me": + apiCookie = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if !strings.Contains(apiCookie, "qttoken=") { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"未登录"}`)) + return + } + _, _ = w.Write([]byte(`{"nickname":"tester","vip":true}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL)) + st := svc.newSourceState(ctx, srv.URL) + st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456") + st.flush() + + if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{ + URL: srv.URL + "/user", Title: "用户后台", + }); err != nil { + t.Fatalf("打开用户后台失败: %v", err) + } + page := waitPending(t, svc, sourceID) + + res, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, srv.URL+"/api/me", + map[string]string{"X-From-Page": "1"}, "") + if err != nil { + t.Fatalf("转发请求失败: %v", err) + } + if res.Status != http.StatusOK { + t.Fatalf("状态 = %d body=%q", res.Status, res.Body) + } + if !strings.Contains(apiCookie, "qttoken=SESSION_abcdef123456") { + t.Fatalf("转发时未带上书源 Cookie: %q", apiCookie) + } + if !strings.Contains(res.Body, `"nickname":"tester"`) { + t.Fatalf("响应体异常: %q", res.Body) + } + if !strings.Contains(res.ContentType, "json") { + t.Fatalf("内容类型 = %q", res.ContentType) + } + if res.Base64 { + t.Fatal("JSON 响应不应走 base64") + } + + // 未知待办:拒绝(链接已过期) + if _, err := svc.ProxyBrowserXHR(ctx, "nope", http.MethodGet, srv.URL+"/api/me", nil, ""); err == nil { + t.Fatal("未知待办应报错") + } + // 非 http(s):拒绝,避免被当成任意协议跳板 + if _, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, "file:///etc/passwd", nil, ""); err == nil { + t.Fatal("非 http(s) 地址应报错") + } +} + +// extractAssetURL 从改写后的页面里取出第一个资源代理地址。 +func extractAssetURL(t *testing.T, html string) string { + t.Helper() + idx := strings.Index(html, "/api/reader/browser/asset?") + if idx < 0 { + t.Fatalf("页面里没有资源代理地址: %q", html) + } + rest := html[idx:] + // 到引号/尖括号为止 + end := strings.IndexAny(rest, `"'< `) + if end < 0 { + end = len(rest) + } + return strings.ReplaceAll(rest[:end], "&", "&") +} + +// TestBrowserPanelUnknownSourceIsolated 待办按书源隔离,避免串台。 +func TestBrowserPanelUnknownSourceIsolated(t *testing.T) { + svc, _ := newLoginTestService(t) + sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com")) + + done := make(chan struct{}, 1) + go func() { + _, _ = svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil) + done <- struct{}{} + }() + page := waitPending(t, svc, sourceID) + + if pages := svc.PendingBrowserPages(readerTestUserID, "other-source"); len(pages) != 0 { + t.Fatalf("其它书源不应看到待办: %+v", pages) + } + // 收尾,避免 goroutine 挂到超时 + _ = svc.ResolveBrowser(page.ID, readerTestUserID, "x", "", false) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("未解除阻塞") + } +} diff --git a/internal/service/reader/http_encoding_test.go b/internal/service/reader/http_encoding_test.go new file mode 100644 index 0000000..6403697 --- /dev/null +++ b/internal/service/reader/http_encoding_test.go @@ -0,0 +1,119 @@ +package reader + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 本文件:规则层必须拿到「解压后」的响应体。 +// +// 回归背景:HTTPHeaderPresets 曾显式写 `Accept-Encoding: gzip, deflate, br`。 +// net/http 只在调用方「没有」设置该头时才会自动补 gzip 并透明解压,显式设置会 +// 让它原样交出压缩字节。于是凡是走了压缩的上游(静态 config.json、CDN 页面等) +// 都会把 gzip 字节喂给书源的 JSON.parse,报 +// SyntaxError: invalid character '\x1f' looking for beginning of value +// 表现出来就是「获取最新配置失败:SyntaxError: Unexpected end of JSON input」, +// 并且书源会误判为线路故障,逐条切换全部线路后仍然失败。 + +// gzipJSONServer 返回一个「客户端支持压缩时才压缩」的站点,模拟真实 CDN。 +func gzipJSONServer(t *testing.T, payload string) (*httptest.Server, *string) { + t.Helper() + var seenAcceptEncoding string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAcceptEncoding = r.Header.Get("Accept-Encoding") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if strings.Contains(seenAcceptEncoding, "gzip") { + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + _, _ = gz.Write([]byte(payload)) + _ = gz.Close() + return + } + _, _ = w.Write([]byte(payload)) + })) + return srv, &seenAcceptEncoding +} + +// readConfigSourceJSON 构造一个用 java.ajax 取配置并 JSON.parse 的书源。 +func readConfigSourceJSON(t *testing.T, serverURL, target string, sourceHeader string) string { + t.Helper() + loginJS := fmt.Sprintf(`function readConfig() { + let data = java.ajax(%q); + let js = JSON.parse(String(data)); + java.longToast('配置版本=' + js.version); +}`, target) + src := map[string]any{ + "bookSourceUrl": serverURL, + "bookSourceName": "压缩测试源", + "loginUrl": loginJS, + "loginUi": `[{"name":"读配置","type":"button","action":"readConfig()"}]`, + } + if sourceHeader != "" { + src["header"] = sourceHeader + } + out, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +// TestReaderJSSeesDecompressedBody 上游压缩的响应必须先解压再进规则层。 +func TestReaderJSSeesDecompressedBody(t *testing.T) { + const payload = `{"version":"20260926"}` + srv, seenAE := gzipJSONServer(t, payload) + defer srv.Close() + + svc, _ := newLoginTestService(t) + sourceID := prepareLoginSource(t, svc, readConfigSourceJSON(t, + srv.URL, srv.URL+"/static/source_config/config.json", "")) + + res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "readConfig()", nil) + if err != nil { + t.Fatalf("动作执行失败: %v", err) + } + if !res.OK { + t.Fatalf("动作未成功: %+v", res) + } + joined := strings.Join(res.Toasts, "\n") + if !strings.Contains(joined, "配置版本=20260926") { + t.Fatalf("规则层拿到的不是解压后的 JSON(压缩字节泄漏到 JSON.parse): %v", res.Toasts) + } + // 必须仍然协商压缩,否则等于用「不压缩」回避问题,真实站点该压还是压 + if !strings.Contains(*seenAE, "gzip") { + t.Fatalf("请求应携带 gzip(由 net/http 自动添加),实际 %q", *seenAE) + } +} + +// TestReaderStripsAcceptEncodingFromSourceHeader 书源 header 字段里的 +// Accept-Encoding 也必须被清掉:显式设置会让 net/http 放弃解压, +// 而且一旦服务端选了 brotli,我们用标准库根本解不出来。 +func TestReaderStripsAcceptEncodingFromSourceHeader(t *testing.T) { + const payload = `{"version":"20260926"}` + srv, seenAE := gzipJSONServer(t, payload) + defer srv.Close() + + svc, _ := newLoginTestService(t) + sourceID := prepareLoginSource(t, svc, readConfigSourceJSON(t, + srv.URL, srv.URL+"/static/source_config/config.json", + `{"Accept-Encoding":"br, gzip"}`)) + + res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "readConfig()", nil) + if err != nil { + t.Fatalf("动作执行失败: %v", err) + } + if !res.OK { + t.Fatalf("动作未成功: %+v", res) + } + if !strings.Contains(strings.Join(res.Toasts, "\n"), "配置版本=20260926") { + t.Fatalf("规则层未拿到解压后的 JSON: %v", res.Toasts) + } + if strings.Contains(*seenAE, "br") { + t.Fatalf("书源 header 里的 Accept-Encoding 未被清理(服务端可能回 brotli): %q", *seenAE) + } +} diff --git a/internal/service/reader/local.go b/internal/service/reader/local.go new file mode 100644 index 0000000..c5d81ed --- /dev/null +++ b/internal/service/reader/local.go @@ -0,0 +1,1332 @@ +// 本地书籍导入与阅读(对应 legado 的本地 TXT / EPUB 书)。 +// +// 存储:正文统一落到 data/reader/local/.,目录信息与网络书 +// 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息: +// - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本) +// - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条) +// 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。 +package reader + +import ( + "archive/zip" + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/xml" + "fmt" + "html" + "io" + "io/fs" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/reader/rule" +) + +// LocalBookMaxBytes 单个本地书籍文件大小上限。 +const LocalBookMaxBytes = 64 << 20 + +// localBooksDirName data 目录下的存放位置。 +const localBooksDirName = "local" + +// txtChapterPattern TXT 目录正则(对齐 legado 默认 TXT 目录规则:RE2 不支持 +// lookbehind,改成行首锚定): +// +// 第 1 章 / 第1章 / 第十二节 / 序章 / 楔子 / 番外 ... +// +// 分成「章标记」+「章名」两段捕获,后面再看章名像不像句子。 +var txtChapterPattern = regexp.MustCompile( + `(?m)^[ \t ]{0,4}(序章|楔子|正文|终章|后记|尾声|番外|第[ \t]{0,4}[0-9〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[ \t]{0,4}[章节卷集部篇])([^\n]{0,40})$`) + +// txtSentenceTail 章名段命中这些,基本可断定这行是正文而不是章名 +// (例如「第一章的正文内容。」)。 +var txtSentenceTail = regexp.MustCompile(`^[的的是了在有和与就都也还很最]|[。!?;:]`) + +// looksLikeChapterTitle 判断匹配到的一行是不是真的章名。 +func looksLikeChapterTitle(marker, rest string) bool { + if marker == "" { + return false + } + if txtSentenceTail.MatchString(strings.TrimSpace(rest)) { + return false + } + // 章名一般不短于标记本身太多,留够「第1章 开端(上)」这类 + return len([]rune(marker+rest)) <= 40 +} + +// txtChapter 章节在规范化后 UTF-8 文本里的字节区间(End 不含)。 +type txtChapter struct { + Title string + Start, End int +} + +// epubChapter EPUB 里的一章:zip 条目路径 + 标题。 +type epubChapter struct { + Title string + Href string +} + +// localBooksDir 本地书籍存放目录(不存在则创建)。 +func (s *ReaderService) localBooksDir() (string, error) { + dir := filepath.Join(s.cfg.App.DataDir, "reader", localBooksDirName) + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("创建本地书籍目录失败: %w", err) + } + return dir, nil +} + +// localFilePath 某本本地书籍的落盘路径。 +// +// LocalExternal 为真时 LocalPath 是服务器上的绝对路径(原地引用,导入时由 +// 管理员选定);否则是 data/reader/local 下的托管副本文件名。 +func (s *ReaderService) localFilePath(book *model.ReaderBook) (string, error) { + if book.LocalPath == "" { + return "", fmt.Errorf("不是本地书籍") + } + if book.LocalExternal { + return filepath.Clean(book.LocalPath), nil + } + dir, err := s.localBooksDir() + if err != nil { + return "", err + } + // 托管副本的文件名只由服务端生成(.),这里再挡一次路径穿越 + return filepath.Join(dir, filepath.Base(book.LocalPath)), nil +} + +// localBookPayload 本地书籍的解析结果(上传与「从服务器路径导入」共用)。 +type localBookPayload struct { + title string + ext string + charset string + chaps []model.ReaderChapter + coverEntry string + // payload 原始字节,仅托管副本模式需要落盘 + payload []byte +} + +// parseLocalBookPayload 按内容识别 TXT / EPUB 并切好目录。 +func parseLocalBookPayload(name string, data []byte) (*localBookPayload, error) { + isEPUB := bytes.HasPrefix(data, []byte("PK\x03\x04")) + if !isEPUB && !strings.EqualFold(filepath.Ext(name), ".epub") && !strings.EqualFold(filepath.Ext(name), ".txt") { + return nil, fmt.Errorf("仅支持 TXT / EPUB 文件") + } + out := &localBookPayload{title: strings.TrimSuffix(name, filepath.Ext(name))} + if isEPUB { + list, cover, err := parseEPUB(data) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, fmt.Errorf("EPUB 里没有可读章节") + } + out.ext, out.coverEntry = ".epub", cover + out.chaps = make([]model.ReaderChapter, 0, len(list)) + for i, c := range list { + out.chaps = append(out.chaps, model.ReaderChapter{Index: i, Title: c.Title, Tag: c.Href}) + } + return out, nil + } + decoded, charset := decodeTextFile(data) + if strings.TrimSpace(decoded) == "" { + return nil, fmt.Errorf("文件内容为空或无法解码") + } + out.ext, out.charset = ".txt", charset + for i, c := range splitTXTChapters(decoded) { + out.chaps = append(out.chaps, model.ReaderChapter{ + Index: i, Title: c.Title, Tag: fmt.Sprintf("%d:%d", c.Start, c.End), + }) + } + return out, nil +} + +// localImportSpec 本地书籍落库参数。payload 非空走托管副本(写入 +// data/reader/local);externalPath 非空走原地引用(LocalPath 直接存它)。 +type localImportSpec struct { + key string // FindBookByURL 的去重键(存进 BookURL) + title string + ext string + charset string + bookType int + chaps []model.ReaderChapter + payload []byte + externalPath string + coverEntry string +} + +// importLocalBook 本地书籍落库共同路径:去重 → 建/更新 → 落内容 → 写目录。 +// +// 重复导入按“覆盖更新”处理:章节数没变就保留阅读进度,变了则退回第一章。 +func (s *ReaderService) importLocalBook( + ctx context.Context, userID string, spec localImportSpec, +) (*model.ReaderBook, error) { + if len(spec.chaps) == 0 { + return nil, fmt.Errorf("未解析出任何章节") + } + book, err := s.repo.FindBookByURL(ctx, userID, "", spec.key) + if err != nil || book == nil { + book = &model.ReaderBook{} + } + isNew := book.ID == "" + if isNew { + book.UserID = userID + book.OriginName = "本地导入" + book.Type = spec.bookType + } + book.Name = spec.title + book.BookURL = spec.key + book.Charset = spec.charset + book.TotalChapterNum = len(spec.chaps) + book.IsLocal = true + + managed := spec.externalPath == "" + if managed { + book.LocalExternal = false + book.LocalPath = "placeholder" + spec.ext // 真正的文件名在写入后按 ID 生成 + } else { + if len(spec.externalPath) > 255 { + return nil, fmt.Errorf("路径过长,无法保存引用") + } + book.LocalExternal = true + book.LocalPath = filepath.Clean(spec.externalPath) + } + + if isNew { + if err := s.repo.CreateBook(ctx, book); err != nil { + return nil, fmt.Errorf("创建本地书籍失败: %w", err) + } + } + if managed { + // 落盘(文件名用 ID,避免同名文件互相覆盖) + book.LocalPath = book.ID + spec.ext + path, err := s.localFilePath(book) + if err != nil { + return nil, err + } + if err := os.WriteFile(path, spec.payload, 0o640); err != nil { + return nil, fmt.Errorf("保存书籍文件失败: %w", err) + } + } + // EPUB 封面:从 zip 里解析出的封面图转成签名资源地址,供书架/阅读页展示。 + // 覆盖导入时按新文件重置,避免留下指向已不存在条目的旧封面。 + if spec.ext == ".epub" { + book.CoverURL = "" + if spec.coverEntry != "" { + book.CoverURL = s.LocalAssetURL(book.ID, spec.coverEntry) + } + } + + if !isNew { + // 覆盖更新:章节数不变就保留进度,否则退回第一章 + if old, err := s.repo.ListChapters(ctx, book.ID); err == nil && len(old) != len(spec.chaps) { + book.DurChapterIndex = 0 + book.DurChapterPos = 0 + book.DurChapterTitle = "" + } + } + chaps := spec.chaps + for i := range chaps { + chaps[i].BookID = book.ID + } + if err := s.repo.ReplaceChapters(ctx, book.ID, chaps); err != nil { + return nil, fmt.Errorf("写入目录失败: %w", err) + } + if err := s.repo.UpdateBook(ctx, book); err != nil { + return nil, fmt.Errorf("更新书籍失败: %w", err) + } + book.IsLocal = true + return book, nil +} + +// ImportLocalBook 导入一本本地书籍(TXT / EPUB),内容复制进 data/reader/local。 +// +// 同名(同 user + 文件名)重复导入按“覆盖更新”处理。 +func (s *ReaderService) ImportLocalBook( + ctx context.Context, userID, filename string, data []byte, +) (*model.ReaderBook, error) { + if len(data) == 0 { + return nil, fmt.Errorf("上传内容为空") + } + if len(data) > LocalBookMaxBytes { + return nil, fmt.Errorf("文件超过 %dMB", LocalBookMaxBytes>>20) + } + name := strings.TrimSpace(filepath.Base(strings.ReplaceAll(filename, "\\", "/"))) + if name == "" || name == "." { + return nil, fmt.Errorf("缺少文件名") + } + parsed, err := parseLocalBookPayload(name, data) + if err != nil { + return nil, err + } + return s.importLocalBook(ctx, userID, localImportSpec{ + key: name, + title: parsed.title, + ext: parsed.ext, + charset: parsed.charset, + chaps: parsed.chaps, + payload: data, + coverEntry: parsed.coverEntry, + }) +} + +// ImportLocalBookFromPath 从服务器上已有的文件导入书籍(TXT / EPUB)。 +// +// 原地引用,不复制到 data/reader/local;同一个文件重复导入按覆盖更新处理, +// 以绝对路径作为去重键(不同目录下的同名文件互不干扰)。 +func (s *ReaderService) ImportLocalBookFromPath( + ctx context.Context, userID, path string, +) (*model.ReaderBook, error) { + abs, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("路径无效: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("文件不存在或不可读") + } + if info.IsDir() { + return nil, fmt.Errorf("请选择文件而不是目录") + } + if info.Size() > LocalBookMaxBytes { + return nil, fmt.Errorf("文件超过 %dMB", LocalBookMaxBytes>>20) + } + f, err := os.Open(abs) // #nosec G304 -- 路径由管理员通过文件选择器选定,handler 已校验允许根目录 + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, LocalBookMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + name := filepath.Base(abs) + parsed, err := parseLocalBookPayload(name, data) + if err != nil { + return nil, err + } + return s.importLocalBook(ctx, userID, localImportSpec{ + key: abs, + title: parsed.title, + ext: parsed.ext, + charset: parsed.charset, + chaps: parsed.chaps, + externalPath: abs, + coverEntry: parsed.coverEntry, + }) +} + +// audioExtensions 有声书识别的音频后缀;.strm 是播放指针,单独处理。 +var audioExtensions = map[string]bool{ + ".mp3": true, ".m4a": true, ".m4b": true, ".aac": true, ".flac": true, + ".wav": true, ".ogg": true, ".oga": true, ".opus": true, ".wma": true, ".ape": true, +} + +// ImportLocalAudioDir 把一个服务器目录导入为一本有声书。 +// +// 目录(含子目录)下的音频文件与 .strm 指针按相对路径排序后逐个成为章节, +// 原地引用不复制;重复导入同一个目录按覆盖更新处理。 +func (s *ReaderService) ImportLocalAudioDir( + ctx context.Context, userID, dir string, +) (*model.ReaderBook, error) { + abs, err := filepath.Abs(strings.TrimSpace(dir)) + if err != nil { + return nil, fmt.Errorf("路径无效: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("目录不存在或不可读") + } + if !info.IsDir() { + return nil, fmt.Errorf("请选择目录") + } + + type audioEntry struct { + path string + rel string + } + var tracks []audioEntry + walkErr := filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil // 单个条目不可读不影响整体 + } + if strings.HasPrefix(d.Name(), ".") && p != abs { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(d.Name())) + if !audioExtensions[ext] && ext != ".strm" { + return nil + } + rel, relErr := filepath.Rel(abs, p) + if relErr != nil { + rel = d.Name() + } + tracks = append(tracks, audioEntry{path: p, rel: rel}) + return nil + }) + if walkErr != nil { + return nil, fmt.Errorf("扫描目录失败: %w", walkErr) + } + if len(tracks) == 0 { + return nil, fmt.Errorf("目录里没有找到音频文件(支持 .strm 播放指针)") + } + sort.Slice(tracks, func(i, j int) bool { return tracks[i].rel < tracks[j].rel }) + + chaps := make([]model.ReaderChapter, 0, len(tracks)) + for i, t := range tracks { + chaps = append(chaps, model.ReaderChapter{ + Index: i, + Title: strings.TrimSuffix(filepath.Base(t.rel), filepath.Ext(t.rel)), + Tag: t.path, + }) + } + return s.importLocalBook(ctx, userID, localImportSpec{ + key: abs, + title: filepath.Base(abs), + bookType: 1, // 音频 + chaps: chaps, + externalPath: abs, + }) +} + +// OpenLocalAudio 打开本地音频文件供流式下发(支持 Range,可拖动进度)。 +func (s *ReaderService) OpenLocalAudio(path string) (*os.File, os.FileInfo, error) { + f, err := os.Open(filepath.Clean(path)) // #nosec G304 -- 路径由签名校验通过 + if err != nil { + return nil, nil, fmt.Errorf("音频文件已丢失") + } + info, err := f.Stat() + if err != nil || info.IsDir() { + f.Close() + return nil, nil, fmt.Errorf("音频文件不可用") + } + return f, info, nil +} + +// BackfillLocalCover 给历史导入的本地 EPUB 补上封面地址。 +// +// 封面是导入时解析出来落库的,早期版本没这一步;在书架加载时自愈一次, +// 用户就不必为了拿到封面而重新导入。返回是否写回了数据库。 +func (s *ReaderService) BackfillLocalCover(ctx context.Context, book *model.ReaderBook) bool { + if book == nil || book.CoverURL != "" || book.LocalPath == "" { + return false + } + if !strings.EqualFold(filepath.Ext(book.LocalPath), ".epub") { + return false + } + path, err := s.localFilePath(book) + if err != nil { + return false + } + f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成 + if err != nil { + return false + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return false + } + zr, err := zip.NewReader(f, st.Size()) + if err != nil { + return false + } + entry := newEPUBZipReader(zr.File).coverEntry() + if entry == "" { + return false + } + book.CoverURL = s.LocalAssetURL(book.ID, entry) + if err := s.repo.UpdateBook(ctx, book); err != nil { + if s.log != nil { + s.log.Warn("reader: 回填本地书籍封面失败", + zap.String("book", book.ID), zap.Error(err)) + } + return false + } + return true +} + +// LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。 +func (s *ReaderService) LocalChapterContent( + ctx context.Context, userID, bookID string, chapterIndex int, +) (*ChapterContent, error) { + book, err := s.repo.GetBook(ctx, bookID) + if err != nil { + return nil, err + } + if book.UserID != userID { + return nil, fmt.Errorf("无权操作他人书架") + } + if book.LocalPath == "" { + return nil, fmt.Errorf("不是本地书籍") + } + chapters, err := s.repo.ListChapters(ctx, bookID) + if err != nil { + return nil, err + } + if chapterIndex < 0 || chapterIndex >= len(chapters) { + return nil, fmt.Errorf("章节序号越界(共 %d 章)", len(chapters)) + } + ch := chapters[chapterIndex] + // 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针 + if book.Type == 1 { + track, transcoding, err := s.localAudioTrack(book, ch) + if err != nil { + return nil, err + } + return &ChapterContent{ + Type: "audio", Tracks: []string{track}, Transcoding: transcoding, declaredType: -1, + }, nil + } + text, err := s.readLocalChapter(book, ch) + if err != nil { + return nil, err + } + // EPUB 里的图片换成签名地址(前端按 [img] 标记渲染) + text = s.rewriteLocalImages(book.ID, text) + text = s.applyUserReplaceRules(ctx, userID, book.Name, text) + return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil +} + +// localAudioTrack 把本地有声书的一章解成可播放的签名地址: +// .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点); +// 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。 +func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderChapter) (string, bool, error) { + target := strings.TrimSpace(ch.Tag) + if target == "" { + return "", false, fmt.Errorf("章节定位信息损坏,请重新导入该书") + } + if strings.EqualFold(filepath.Ext(target), ".strm") { + raw, err := readSTRMFile(target) + if err != nil { + return "", false, fmt.Errorf("读取 .strm 失败: %w", err) + } + if raw == "" { + return "", false, fmt.Errorf("该 .strm 里没有可用的播放地址") + } + // 本地路径:相对 .strm 所在目录解析 + target = raw + if !isRemoteMediaURL(target) && !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(ch.Tag), target) + } + } + if needsAudioTranscode(target) { + if s.ffmpegBinary() == "" { + return "", false, transcodeMissingFFmpegError(target) + } + return s.AudioTranscodeURL(book.ID, target), true, nil + } + if isRemoteMediaURL(target) { + return s.ProxyURL(book.ID, target), false, nil + } + return s.LocalAudioURL(book.ID, filepath.Clean(target)), false, nil +} + +// readSTRMFile 读取 .strm 指针文件里的第一条有效内容(约定为一行播放地址)。 +func readSTRMFile(path string) (string, error) { + data, err := os.ReadFile(path) // #nosec G304 -- 路径来自管理员导入时选定的目录 + if err != nil { + return "", err + } + for _, line := range strings.Split(string(data), "\n") { + candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) + if candidate == "" || strings.HasPrefix(candidate, "#") { + continue + } + return candidate, nil + } + return "", nil +} + +// isRemoteMediaURL 判断 .strm 内容是不是远端地址(http/网盘协议或本机播放端点); +// 不是的话按同目录下的本地文件路径处理。 +func isRemoteMediaURL(raw string) bool { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "/api/") || strings.HasPrefix(raw, "/Videos/") || strings.HasPrefix(raw, "/videos/") { + return true + } + u, err := url.Parse(raw) + if err != nil { + return false + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists": + return true + } + return false +} + +// readLocalChapter 取一章正文:TXT 读字节区间,EPUB 解压对应条目再转纯文本。 +func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.ReaderChapter) (string, error) { + path, err := s.localFilePath(book) + if err != nil { + return "", err + } + if strings.HasSuffix(book.LocalPath, ".epub") { + return readEPUBEntry(path, ch.Tag) + } + start, end, ok := parseByteRange(ch.Tag) + if !ok { + return "", fmt.Errorf("章节定位信息损坏,请重新导入该书") + } + f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成 + if err != nil { + return "", fmt.Errorf("本地书籍文件已丢失: %w", err) + } + defer f.Close() + if end <= start { + return "", nil + } + buf := make([]byte, end-start) + if _, err := f.ReadAt(buf, int64(start)); err != nil && err != io.EOF { + return "", fmt.Errorf("读取章节失败: %w", err) + } + return strings.TrimSpace(string(buf)), nil +} + +// DeleteLocalBookFile 移出书架时删除本地文件(网络书籍无文件,直接返回)。 +// +// 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。 +func (s *ReaderService) DeleteLocalBookFile(book *model.ReaderBook) { + if book == nil || book.LocalPath == "" || book.LocalExternal { + return + } + path, err := s.localFilePath(book) + if err != nil { + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) && s.log != nil { + s.log.Warn("reader: 删除本地书籍文件失败", + zap.String("book", book.ID), zap.Error(err)) + } +} + +// parseByteRange 解析 "start:end"。 +func parseByteRange(tag string) (int, int, bool) { + parts := strings.SplitN(tag, ":", 2) + if len(parts) != 2 { + return 0, 0, false + } + var start, end int + if _, err := fmt.Sscanf(parts[0], "%d", &start); err != nil { + return 0, 0, false + } + if _, err := fmt.Sscanf(parts[1], "%d", &end); err != nil { + return 0, 0, false + } + if start < 0 || end < start { + return 0, 0, false + } + return start, end, true +} + +// ─── TXT ────────────────────────────────────────────────────────────────── + +// decodeTextFile 识别编码并统一转成 UTF-8 字符串,返回检测到的字符集名。 +func decodeTextFile(data []byte) (string, string) { + // UTF-8 BOM + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + return string(data[3:]), "utf-8" + } + // UTF-16 BOM + if len(data) >= 2 { + if data[0] == 0xFF && data[1] == 0xFE { + if s, err := rule.DecodeBytes(data, "utf-16le"); err == nil { + return strings.TrimPrefix(s, "\uFEFF"), "utf-16le" + } + } + if data[0] == 0xFE && data[1] == 0xFF { + if s, err := rule.DecodeBytes(data, "utf-16be"); err == nil { + return strings.TrimPrefix(s, "\uFEFF"), "utf-16be" + } + } + } + if utf8.Valid(data) { + return string(data), "utf-8" + } + // 中文小说最常见的另一种编码 + if s, err := rule.DecodeBytes(data, "gbk"); err == nil && utf8.ValidString(s) { + return s, "gbk" + } + if s, err := rule.DecodeBytes(data, "big5"); err == nil && utf8.ValidString(s) { + return s, "big5" + } + return string(data), "utf-8" +} + +// splitTXTChapters 按目录正则切章;切不出来就整本当一章。 +// 用字节下标而不是 rune 下标,方便直接按区间读文件。 +func splitTXTChapters(text string) []txtChapter { + matches := txtChapterPattern.FindAllStringSubmatchIndex(text, -1) + var starts []int + var titles []string + for _, m := range matches { + // m: [整行起, 整行止, 标记起, 标记止, 章名起, 章名止] + marker := text[m[2]:m[3]] + rest := text[m[4]:m[5]] + if !looksLikeChapterTitle(marker, rest) { + continue + } + starts = append(starts, m[0]) + titles = append(titles, strings.TrimSpace(text[m[0]:m[1]])) + } + if len(starts) == 0 { + return []txtChapter{{Title: "全文", Start: 0, End: len(text)}} + } + out := make([]txtChapter, 0, len(starts)) + for i, start := range starts { + end := len(text) + if i+1 < len(starts) { + end = starts[i+1] + } + // 章前内容(书名、作者、简介)跟着第一章 + if i == 0 { + start = 0 + } + out = append(out, txtChapter{Title: titles[i], Start: start, End: end}) + } + return out +} + +// ─── EPUB ───────────────────────────────────────────────────────────────── + +// parseEPUB 读 EPUB 的 OPF spine,得到有序章节;标题优先取 NCX/NAV 目录,其次取 +// 正文里的首个标题标签,最后退回“第 N 章”。同时返回封面图在 zip 里的条目路径 +// (取不到返回空串)。 +func parseEPUB(data []byte) ([]epubChapter, string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, "", fmt.Errorf("EPUB 损坏(不是有效的 zip): %w", err) + } + return parseEPUBReader(newEPUBZipReader(zr.File)) +} + +// epubZipReader 已打开 zip 的条目索引,章节解析与封面回填共用。 +type epubZipReader struct { + entries map[string]*zip.File +} + +func newEPUBZipReader(files []*zip.File) *epubZipReader { + entries := make(map[string]*zip.File, len(files)) + for _, f := range files { + entries[filepath.Clean(f.Name)] = f + } + return &epubZipReader{entries: entries} +} + +func (r *epubZipReader) read(name string) ([]byte, error) { + f, ok := r.entries[filepath.Clean(name)] + if !ok { + return nil, fmt.Errorf("EPUB 缺少条目 %s", name) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes)) +} + +// coverEntry 解析 OPF 里的封面声明,返回封面图在 zip 里的条目路径(取不到为空)。 +func (r *epubZipReader) coverEntry() string { + opfPath, err := epubOPFPath(r.read) + if err != nil { + return "" + } + opfRaw, err := r.read(opfPath) + if err != nil { + return "" + } + opf, err := parseOPF(opfRaw) + if err != nil { + return "" + } + return epubCoverEntry(r.entries, filepath.Dir(opfPath), opf.coverHref) +} + +// parseEPUBReader 从已打开的 zip 解析章节与封面。 +func parseEPUBReader(er *epubZipReader) ([]epubChapter, string, error) { + readEntry := er.read + + opfPath, err := epubOPFPath(readEntry) + if err != nil { + return nil, "", err + } + opfRaw, err := readEntry(opfPath) + if err != nil { + return nil, "", err + } + opf, err := parseOPF(opfRaw) + if err != nil { + return nil, "", err + } + opfDir := filepath.Dir(opfPath) + + cover := epubCoverEntry(er.entries, opfDir, opf.coverHref) + + // href(相对 OPF 目录)→ 标题 + titles := map[string]string{} + for _, href := range []string{opf.ncxHref, opf.navHref} { + if href == "" { + continue + } + raw, err := readEntry(filepath.Join(opfDir, href)) + if err != nil { + continue + } + for k, v := range parseEPUBTitles(raw) { + full := filepath.Clean(filepath.Join(opfDir, k)) + if _, ok := titles[full]; !ok { + titles[full] = v + } + } + } + + out := make([]epubChapter, 0, len(opf.spine)) + for _, href := range opf.spine { + full := filepath.Clean(filepath.Join(opfDir, href)) + raw, err := readEntry(filepath.Join(opfDir, href)) + if err != nil { + continue + } + title := titles[full] + if title == "" { + // NCX/NAV 里没有这页:正文首行 → 封面页 → → 序号 + body := epubHTMLToText(string(raw), filepath.Dir(full)) + title = firstHeading(body) + if title == "" && coverLikeEntry(full) && strings.Contains(body, imgMarkerPrefix) { + title = "封面" + } + if title == "" { + title = meaningfulTitle(htmlHeadTitle(string(raw))) + } + if title == "" { + title = fmt.Sprintf("第 %d 章", len(out)+1) + } + } + out = append(out, epubChapter{Title: title, Href: full}) + } + return out, cover, nil +} + +// epubCoverEntry 定位封面图在 zip 里的条目路径: +// 优先用 OPF 声明(EPUB3 manifest 的 properties="cover-image" 或 EPUB2 的 +// );声明缺失或指向不存在的条目时,退回文件名含 cover 的图片。 +func epubCoverEntry(entries map[string]*zip.File, opfDir, declaredHref string) string { + if declaredHref != "" { + full := filepath.Clean(filepath.Join(opfDir, declaredHref)) + if f, ok := entries[full]; ok && isImageEntry(f.Name) { + return full + } + } + // 兜底:名字像封面(cover.jpg / 封面.png / images/cover.jpeg)的图片 + var fallback string + for name := range entries { + base := strings.ToLower(filepath.Base(name)) + if !strings.Contains(base, "cover") && !strings.Contains(base, "封面") { + continue + } + if !isImageEntry(name) { + continue + } + // 多个候选时取路径最短的,通常就是主封面 + if fallback == "" || len(name) < len(fallback) { + fallback = name + } + } + return fallback +} + +// isImageEntry 判断 zip 条目是不是图片。 +func isImageEntry(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".svg": + return true + } + return false +} + +// coverLikeEntry 文件名像封面页(cover1.html / cover.xhtml / 封面.jpg 等)。 +func coverLikeEntry(entry string) bool { + name := strings.ToLower(filepath.Base(entry)) + return strings.Contains(name, "cover") || strings.Contains(name, "封面") +} + +// meaningfulTitle 过滤掉封面/版权页那类无信息量的 。 +func meaningfulTitle(title string) string { + switch strings.ToLower(strings.TrimSpace(title)) { + case "", "cover", "table of contents", "contents", "unknown", "untitled", "title": + return "" + } + return title +} + +// epubOPFPath 从 META-INF/container.xml 找 OPF 路径。 +func epubOPFPath(readEntry func(string) ([]byte, error)) (string, error) { + raw, err := readEntry("META-INF/container.xml") + if err != nil { + return "", fmt.Errorf("EPUB 缺少 META-INF/container.xml") + } + var c struct { + Rootfiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` + } + if err := xml.Unmarshal(raw, &c); err != nil { + return "", fmt.Errorf("解析 container.xml 失败: %w", err) + } + for _, rf := range c.Rootfiles { + if p := strings.TrimSpace(rf.FullPath); p != "" { + return filepath.Clean(p), nil + } + } + return "", fmt.Errorf("container.xml 里没有 rootfile") +} + +type epubPackage struct { + spine []string + ncxHref string + navHref string + coverHref string // 封面图 href(相对 OPF 目录),取不到为空 +} + +// parseOPF 解析 OPF:manifest 建 id→href 索引,spine 定顺序,顺带找出 NCX/NAV 目录 +// 与封面图。 +func parseOPF(raw []byte) (*epubPackage, error) { + var pkg struct { + Metadata struct { + Metas []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` + } `xml:"metadata"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` + Properties string `xml:"properties,attr"` + } `xml:"item"` + } `xml:"manifest"` + Spine struct { + Toc string `xml:"toc,attr"` + ItemRefs []struct { + IDRef string `xml:"idref,attr"` + } `xml:"itemref"` + } `xml:"spine"` + } + if err := xml.Unmarshal(raw, &pkg); err != nil { + return nil, fmt.Errorf("解析 OPF 失败: %w", err) + } + hrefByID := map[string]string{} + out := &epubPackage{} + for _, it := range pkg.Manifest.Items { + if it.ID != "" { + hrefByID[it.ID] = it.Href + } + switch { + case strings.Contains(it.Properties, "nav"): + out.navHref = it.Href + case strings.EqualFold(it.MediaType, "application/x-dtbncx+xml"): + if out.ncxHref == "" { + out.ncxHref = it.Href + } + case strings.Contains(it.Properties, "cover-image"): + // EPUB3 封面 + if out.coverHref == "" { + out.coverHref = it.Href + } + } + } + // EPUB2 封面: + if out.coverHref == "" { + for _, m := range pkg.Metadata.Metas { + if !strings.EqualFold(strings.TrimSpace(m.Name), "cover") { + continue + } + if href := hrefByID[strings.TrimSpace(m.Content)]; href != "" { + out.coverHref = href + break + } + } + } + if out.ncxHref == "" && pkg.Spine.Toc != "" { + out.ncxHref = hrefByID[pkg.Spine.Toc] + } + for _, ref := range pkg.Spine.ItemRefs { + href := hrefByID[ref.IDRef] + if href == "" { + continue + } + if !strings.HasPrefix(href, "#") { + out.spine = append(out.spine, href) + } + } + if len(out.spine) == 0 { + return nil, fmt.Errorf("EPUB spine 为空") + } + return out, nil +} + +// parseEPUBTitles 从 NCX(navPoint 树)或 EPUB3 NAV(nav 里的 a)取 相对href → 标题。 +// +// 用 token 逐个走而不是结构体映射:网上不少 EPUB(如 Epubor 导出的)navPoint +// 漏了闭合标签,标题会整棵挂在上一个 navPoint 里面,按固定层级解会丢掉一大半; +// 这里用栈把任意层级的 navPoint 都收下来。 +func parseEPUBTitles(raw []byte) map[string]string { + out := map[string]string{} + dec := xml.NewDecoder(bytes.NewReader(raw)) + var ( + stack []navItem + labelDep int // >0 表示在 navLabel 内 + navDep int // >0 表示在 内 + inAnchor bool // 正在读 … + href string + text strings.Builder + ) + pop := func() { + if n := len(stack); n > 0 { + it := stack[n-1] + stack = stack[:n-1] + putTitle(out, it.src, it.text) + } + } + for { + tok, err := dec.Token() + if err != nil { + break + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "navPoint": + stack = append(stack, navItem{}) + case "navLabel": + labelDep++ + case "text": + if labelDep > 0 && len(stack) > 0 && stack[len(stack)-1].text == "" { + var s string + if dec.DecodeElement(&s, &t) == nil { + stack[len(stack)-1].text = strings.TrimSpace(s) + } + continue + } + case "content": + if len(stack) > 0 && stack[len(stack)-1].src == "" { + for _, a := range t.Attr { + if a.Name.Local == "src" { + stack[len(stack)-1].src = a.Value + } + } + } + case "nav": + navDep++ + case "a": + if navDep > 0 { + inAnchor, href = true, "" + text.Reset() + for _, a := range t.Attr { + if a.Name.Local == "href" { + href = a.Value + } + } + } + } + case xml.CharData: + if inAnchor { + text.Write(t) + } + case xml.EndElement: + switch t.Name.Local { + case "navLabel": + if labelDep > 0 { + labelDep-- + } + case "navPoint": + pop() + case "nav": + if navDep > 0 { + navDep-- + } + case "a": + if inAnchor { + inAnchor = false + putTitle(out, href, strings.TrimSpace(text.String())) + } + } + } + } + // 兜底:文件尾还有没闭合的 navPoint + for range stack { + pop() + } + return out +} + +// navItem NCX 里的一个 navPoint。 +type navItem struct{ src, text string } + +// putTitle 记录 href → 标题(去掉锚点、去重,先到先得)。 +func putTitle(out map[string]string, href, title string) { + href = strings.TrimSpace(href) + if i := strings.IndexByte(href, '#'); i >= 0 { + href = href[:i] + } + if href == "" || title == "" { + return + } + if _, ok := out[href]; !ok { + out[href] = title + } +} + +// readEPUBEntry 读 zip 内某个 XHTML 并转纯文本(图片转成 [img] 标记行)。 +func readEPUBEntry(epubPath, href string) (string, error) { + zr, err := zip.OpenReader(epubPath) // #nosec G304 -- 服务端生成的路径 + if err != nil { + return "", fmt.Errorf("本地书籍文件已丢失: %w", err) + } + defer zr.Close() + target := filepath.Clean(href) + for _, f := range zr.File { + if filepath.Clean(f.Name) != target { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + raw, err := io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes)) + if err != nil { + return "", err + } + return epubHTMLToText(string(raw), filepath.Dir(target)), nil + } + return "", fmt.Errorf("EPUB 条目不存在,请重新导入该书") +} + +// ReadLocalAsset 读取本地书籍里的资源(目前用于 EPUB 图片)。 +// 返回内容与 MIME 类型。 +func (s *ReaderService) ReadLocalAsset(ctx context.Context, bookID, entry string) ([]byte, string, error) { + book, err := s.repo.GetBook(ctx, bookID) + if err != nil { + return nil, "", err + } + if !strings.HasSuffix(book.LocalPath, ".epub") { + return nil, "", fmt.Errorf("该书籍没有内嵌资源") + } + path, err := s.localFilePath(book) + if err != nil { + return nil, "", err + } + zr, err := zip.OpenReader(path) // #nosec G304 -- 服务端生成的路径 + if err != nil { + return nil, "", fmt.Errorf("本地书籍文件已丢失: %w", err) + } + defer zr.Close() + target := filepath.Clean(entry) + for _, f := range zr.File { + if filepath.Clean(f.Name) != target { + continue + } + rc, err := f.Open() + if err != nil { + return nil, "", err + } + defer rc.Close() + data, err := io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes)) + if err != nil { + return nil, "", err + } + return data, imageContentType(entry), nil + } + return nil, "", fmt.Errorf("EPUB 内没有该资源") +} + +// imageContentType 按扩展名给图片类型(不依赖系统 mime 注册表)。 +func imageContentType(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".svg": + return "image/svg+xml" + case ".bmp": + return "image/bmp" + case ".avif": + return "image/avif" + } + return "application/octet-stream" +} + +// imgTagPattern 匹配 ,取 src。 +var imgTagPattern = regexp.MustCompile(`(?is)]*?src\s*=\s*("([^"]*)"|'([^']*)')[^>]*>`) + +// imgMarkerPrefix 图片在正文里的占位行前缀,前端据此渲染 。 +const imgMarkerPrefix = "[img]" + +// epubHTMLToText EPUB 正文转纯文本:先把 换成 [img]条目路径 标记行, +// 再按普通 HTML 去标签。baseDir 是当前 XHTML 所在目录,用来解析相对路径。 +func epubHTMLToText(src, baseDir string) string { + src = imgTagPattern.ReplaceAllStringFunc(src, func(tag string) string { + m := imgTagPattern.FindStringSubmatch(tag) + if m == nil { + return "" + } + ref := strings.TrimSpace(m[2] + m[3]) + if ref == "" { + return "" + } + ref = strings.ReplaceAll(html.UnescapeString(ref), "\\", "/") + // data: / 外链图片原样保留;相对路径按当前文件目录解析成 zip 条目 + if !strings.Contains(ref, "://") && !strings.HasPrefix(ref, "data:") { + if i := strings.IndexByte(ref, '#'); i >= 0 { + ref = ref[:i] + } + ref = filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(ref))) + } + return "\n" + imgMarkerPrefix + ref + "\n" + }) + return htmlToText(src) +} + +// htmlHeadTitle 取 文本(EPUB 里常与正文标题一致)。 +func htmlHeadTitle(src string) string { + m := headTitlePattern.FindStringSubmatch(src) + if m == nil { + return "" + } + return strings.TrimSpace(html.UnescapeString(m[1])) +} + +var headTitlePattern = regexp.MustCompile(`(?is)]*>(.*?)`) + +// rewriteLocalImages 把正文里的 [img]条目 标记换成签名资源地址。 +func (s *ReaderService) rewriteLocalImages(bookID, text string) string { + if !strings.Contains(text, imgMarkerPrefix) { + return text + } + lines := strings.Split(text, "\n") + for i, line := range lines { + if !strings.HasPrefix(line, imgMarkerPrefix) { + continue + } + entry := strings.TrimSpace(strings.TrimPrefix(line, imgMarkerPrefix)) + if entry == "" || strings.HasPrefix(entry, "/api/") || strings.Contains(entry, "://") { + continue + } + lines[i] = imgMarkerPrefix + s.LocalAssetURL(bookID, entry) + } + return strings.Join(lines, "\n") +} + +// signLocalAsset 本地书籍资源的 HMAC 签名(与媒体代理同一套: 带不了 JWT)。 +func (s *ReaderService) signLocalAsset(bookID, entry string) string { + mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret)) + mac.Write([]byte(bookID + "|local|" + entry)) + return hex.EncodeToString(mac.Sum(nil))[:32] +} + +// LocalAssetURL 本地书籍内资源转签名代理地址。 +func (s *ReaderService) LocalAssetURL(bookID, entry string) string { + return "/api/reader/local/asset?b=" + url.QueryEscape(bookID) + + "&p=" + base64.RawURLEncoding.EncodeToString([]byte(entry)) + + "&s=" + s.signLocalAsset(bookID, entry) +} + +// VerifyLocalAssetURL 校验签名并还原条目路径。 +func (s *ReaderService) VerifyLocalAssetURL(bookID, encoded, sig string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("资源路径解码失败") + } + expect := s.signLocalAsset(bookID, string(raw)) + if !hmac.Equal([]byte(expect), []byte(sig)) { + return "", fmt.Errorf("资源签名校验失败") + } + return string(raw), nil +} + +// signLocalAudio 本地有声书音频文件的 HMAC 签名(与本地资源同一套: 带不了 JWT)。 +func (s *ReaderService) signLocalAudio(bookID, path string) string { + mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret)) + mac.Write([]byte(bookID + "|audio|" + path)) + return hex.EncodeToString(mac.Sum(nil))[:32] +} + +// LocalAudioURL 本地音频文件转签名流地址(服务端按 Range 下发,可拖动进度)。 +func (s *ReaderService) LocalAudioURL(bookID, path string) string { + return "/api/reader/local/audio?b=" + url.QueryEscape(bookID) + + "&p=" + base64.RawURLEncoding.EncodeToString([]byte(path)) + + "&s=" + s.signLocalAudio(bookID, path) +} + +// VerifyLocalAudioURL 校验签名并还原音频文件路径。 +func (s *ReaderService) VerifyLocalAudioURL(bookID, encoded, sig string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("音频路径解码失败") + } + expect := s.signLocalAudio(bookID, string(raw)) + if !hmac.Equal([]byte(expect), []byte(sig)) { + return "", fmt.Errorf("音频签名校验失败") + } + return string(raw), nil +} + +// firstHeading 取正文首个非空行当标题(跳过图片占位行)。 +func firstHeading(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, imgMarkerPrefix) { + continue + } + if r := []rune(line); len(r) > 40 { + return string(r[:40]) + } + return line + } + return "" +} + +// htmlToText 把 XHTML 转成纯文本:丢 script/style、块级标签转换行、解码实体。 +func htmlToText(src string) string { + src = regexp.MustCompile(`(?is)`) + add("OEBPS/ch2.xhtml", `第二章正文。又一段。`) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestImportEPUBAndRead(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + book, err := svc.ImportLocalBook(ctx, "u1", "测试电子书.epub", epubFixture(t)) + if err != nil { + t.Fatalf("导入 EPUB 失败: %v", err) + } + if book.TotalChapterNum != 2 { + t.Fatalf("章数 = %d,期望 2", book.TotalChapterNum) + } + chapters, err := svc.ListChapters(ctx, book.ID) + if err != nil || len(chapters) != 2 { + t.Fatalf("目录 = %+v, err=%v", chapters, err) + } + if chapters[0].Title != "第一章 起风" || chapters[1].Title != "第二章 落雨" { + t.Fatalf("章节标题应取自 NCX: %q / %q", chapters[0].Title, chapters[1].Title) + } + // 标题不要留在正文里 + first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) + if err != nil { + t.Fatalf("读第一章失败: %v", err) + } + if !strings.Contains(first.Content, "第一段 & 实体。") { + t.Errorf("实体未解码: %q", first.Content) + } + if !strings.Contains(first.Content, "第一段 & 实体。\n第二段。") { + t.Errorf("段落换行不对: %q", first.Content) + } + if strings.Contains(first.Content, "var x=1") { + t.Errorf("script 应被剔除: %q", first.Content) + } + second, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) + if err != nil || !strings.Contains(second.Content, "第二章正文。") { + t.Fatalf("读第二章失败: %v / %q", err, second.Content) + } +} + +// TestImportEPUBWithImage 端到端:导入带图片的 EPUB,章节正文里图片应变成签名 +// 地址,且该地址能取回原始图片字节。 +func TestImportEPUBWithImage(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name, body string) { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + imgBytes := []byte("\xff\xd8\xff\xe0fakejpeg") + add("mimetype", "application/epub+zip") + add("META-INF/container.xml", ` + `) + add("OEBPS/content.opf", ` + + + + + + `) + add("OEBPS/nav.xhtml", ` + 插图章`) + add("OEBPS/ch1.xhtml", `插图章 + 图片前。图片后。`) + w, err := zw.Create("OEBPS/images/pic.jpg") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(imgBytes); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + book, err := svc.ImportLocalBook(ctx, "u1", "带图.epub", buf.Bytes()) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + chapters, err := svc.ListChapters(ctx, book.ID) + if err != nil || len(chapters) != 1 || chapters[0].Title != "插图章" { + t.Fatalf("目录 = %+v, err=%v", chapters, err) + } + content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) + if err != nil { + t.Fatalf("读正文失败: %v", err) + } + if !strings.Contains(content.Content, "图片前。") || !strings.Contains(content.Content, "图片后。") { + t.Fatalf("正文缺内容: %q", content.Content) + } + var asset string + for _, line := range strings.Split(content.Content, "\n") { + if strings.HasPrefix(line, imgMarkerPrefix) { + asset = strings.TrimPrefix(line, imgMarkerPrefix) + } + } + if !strings.HasPrefix(asset, "/api/reader/local/asset?") { + t.Fatalf("图片未换成签名地址: %q(正文 %q)", asset, content.Content) + } + u, err := url.Parse(asset) + if err != nil { + t.Fatal(err) + } + entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) + if err != nil { + t.Fatalf("资源签名校验失败: %v", err) + } + data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) + if err != nil { + t.Fatalf("读图片失败: %v", err) + } + if !bytes.Equal(data, imgBytes) { + t.Fatalf("图片字节不一致: %q", data) + } + if ct != "image/jpeg" { + t.Fatalf("Content-Type = %q", ct) + } +} + +func TestHTMLToText(t *testing.T) { + got := htmlToText("甲\n乙丙") + if got != "甲\n乙\n丙" { + t.Fatalf("htmlToText = %q", got) + } +} + +// TestParseEPUBTitlesHandlesNestedNavPoint 网上不少 EPUB(Epubor 导出)navPoint +// 漏了闭合标签,标题会整棵挂进上一个 navPoint。按固定层级解会丢一大半标题, +// 这里要求深层节点也能收全。 +func TestParseEPUBTitlesHandlesNestedNavPoint(t *testing.T) { + raw := []byte(` + + UnKnown + + 目录 + 小狗钱钱1 + 童话与理财 + 前言 + + +`) + titles := parseEPUBTitles(raw) + want := map[string]string{ + "text00000.html": "目录", + "text00002.html": "小狗钱钱1", + "text00005.html": "童话与理财", + "text00006.html": "前言", + } + for href, title := range want { + if titles[href] != title { + t.Errorf("%s 标题 = %q,期望 %q(全部:%v)", href, titles[href], title, titles) + } + } + if titles["UnKnown"] != "" { + t.Errorf("docTitle 不该被当成章节标题:%v", titles) + } +} + +// TestParseEPUBTitlesNav 兼容 EPUB3 的 nav 目录(含带锚点与嵌套 ol 的情况)。 +func TestParseEPUBTitlesNav(t *testing.T) { + raw := []byte(` + + +目录 + 第一章 起风 + 第二章 落雨 + 第三章 天晴 + + +`) + titles := parseEPUBTitles(raw) + for href, title := range map[string]string{ + "ch1.xhtml": "第一章 起风", + "ch2.xhtml": "第二章 落雨", + "ch3.xhtml": "第三章 天晴", + } { + if titles[href] != title { + t.Errorf("%s 标题 = %q,期望 %q(全部:%v)", href, titles[href], title, titles) + } + } +} + +// TestEpubHTMLToTextWithImages 图片要变成 [img]条目 标记行,且相对路径按正文所在 +// 目录解析;标记行不能带缩进(前端据此渲染图片)。 +func TestEpubHTMLToTextWithImages(t *testing.T) { + src := `第一章 +正文一。 + +正文二尾巴。 + +` + got := epubHTMLToText(src, "OEBPS") + want := "第一章\n正文一。\n" + imgMarkerPrefix + filepath.Join("OEBPS", "Image00024.jpg") + + "\n正文二\n" + imgMarkerPrefix + filepath.Join("OEBPS", "sub", "pic.png") + "\n尾巴。\n" + + imgMarkerPrefix + "http://cdn.example.com/a.jpg" + if got != want { + t.Fatalf("epubHTMLToText =\n%q\n期望\n%q", got, want) + } +} + +// TestRewriteLocalImages 标记里的条目要换成签名地址,且重复处理不会二次改写。 +func TestRewriteLocalImages(t *testing.T) { + svc := newLocalBookService(t) + text := imgMarkerPrefix + filepath.Join("OEBPS", "a.jpg") + "\n正文\n" + imgMarkerPrefix + "http://x/b.png" + out := svc.rewriteLocalImages("book-1", text) + lines := strings.Split(out, "\n") + if !strings.HasPrefix(lines[0], imgMarkerPrefix+"/api/reader/local/asset?b=book-1&p=") { + t.Fatalf("相对路径未改写:%q", lines[0]) + } + if !strings.HasSuffix(lines[2], "http://x/b.png") { + t.Fatalf("外链图片不该改写:%q", lines[2]) + } + // 再跑一次应保持不变 + if again := svc.rewriteLocalImages("book-1", out); again != out { + t.Fatalf("重复改写改变了结果:%q", again) + } + // 签名可校验,改一个字符就不认 + entry := filepath.Join("OEBPS", "a.jpg") + u := svc.LocalAssetURL("book-1", entry) + parsed, err := url.Parse(u) + if err != nil { + t.Fatal(err) + } + got, err := svc.VerifyLocalAssetURL("book-1", parsed.Query().Get("p"), parsed.Query().Get("s")) + if err != nil || got != entry { + t.Fatalf("签名校验失败: %v / %q", err, got) + } + if _, err := svc.VerifyLocalAssetURL("book-1", parsed.Query().Get("p"), "deadbeef"); err == nil { + t.Fatal("错误签名应校验失败") + } + if _, err := svc.VerifyLocalAssetURL("book-2", parsed.Query().Get("p"), parsed.Query().Get("s")); err == nil { + t.Fatal("换一本书后签名应失效") + } +} + +// epubWithCoverFixture 造一个带封面的最小 EPUB。opfCover 决定封面怎么写: +// EPUB3 用 properties="cover-image",EPUB2 用 ;都为 false +// 时封面只能靠文件名兜底。 +func epubWithCoverFixture(t *testing.T, epub3, epub2 bool) ([]byte, []byte) { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name, body string) { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + coverBytes := []byte("\xff\xd8\xff\xe0fakecoverjpeg") + add("mimetype", "application/epub+zip") + add("META-INF/container.xml", ` + + +`) + meta := "" + if epub2 { + meta = `` + } + props := "" + if epub3 { + props = ` properties="cover-image"` + } + add("OEBPS/content.opf", ` +`+meta+` + + + + + +`) + add("OEBPS/ch1.xhtml", `正文内容。`) + w, err := zw.Create("OEBPS/images/cover.jpg") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(coverBytes); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes(), coverBytes +} + +// TestImportEPUBCover 封面要能从 OPF 声明(EPUB3 / EPUB2)与文件名兜底三条路 +// 解析出来,并转成可访问的签名资源地址。 +func TestImportEPUBCover(t *testing.T) { + ctx := t.Context() + cases := []struct { + name string + epub3, epub2 bool + }{ + {"EPUB3 cover-image", true, false}, + {"EPUB2 meta cover", false, true}, + {"文件名兜底", false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newLocalBookService(t) + raw, coverBytes := epubWithCoverFixture(t, tc.epub3, tc.epub2) + book, err := svc.ImportLocalBook(ctx, "u1", "有封面.epub", raw) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { + t.Fatalf("封面未生成签名地址: %q", book.CoverURL) + } + u, err := url.Parse(book.CoverURL) + if err != nil { + t.Fatal(err) + } + entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) + if err != nil { + t.Fatalf("封面签名校验失败: %v", err) + } + data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) + if err != nil { + t.Fatalf("读封面失败: %v", err) + } + if !bytes.Equal(data, coverBytes) { + t.Fatalf("封面字节不一致: %q", data) + } + if ct != "image/jpeg" { + t.Fatalf("封面 MIME = %q,期望 image/jpeg", ct) + } + + // 落库后书架列表/详情也要带上封面,重开服务不丢 + books, err := svc.ListBooks(ctx, "u1") + if err != nil || len(books) != 1 || books[0].CoverURL != book.CoverURL { + t.Fatalf("书架封面 = %+v, err=%v", books, err) + } + }) + } +} + +// TestBackfillLocalCoverOnListBooks 早期导入的本地 EPUB 没存封面, +// 加载书架时应该自动补上并落库,不必让用户重新导入。 +func TestBackfillLocalCoverOnListBooks(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + raw, coverBytes := epubWithCoverFixture(t, true, false) + book, err := svc.ImportLocalBook(ctx, "u1", "老书.epub", raw) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + // 模拟修复前导入的旧数据:库里没有封面 + book.CoverURL = "" + if err := svc.repo.UpdateBook(ctx, book); err != nil { + t.Fatal(err) + } + + books, err := svc.ListBooks(ctx, "u1") + if err != nil || len(books) != 1 { + t.Fatalf("书架 = %+v, err=%v", books, err) + } + restored := books[0].CoverURL + if !strings.HasPrefix(restored, "/api/reader/local/asset?") { + t.Fatalf("书架加载未回填封面: %q", restored) + } + + // 已经写回数据库,下次不用再解析 + stored, err := svc.repo.GetBook(ctx, book.ID) + if err != nil || stored.CoverURL != restored { + t.Fatalf("封面未落库: %+v, err=%v", stored, err) + } + + // 回填出来的地址必须真能取到封面图 + u, err := url.Parse(restored) + if err != nil { + t.Fatal(err) + } + entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) + if err != nil { + t.Fatalf("回填封面签名校验失败: %v", err) + } + data, _, err := svc.ReadLocalAsset(ctx, book.ID, entry) + if err != nil || !bytes.Equal(data, coverBytes) { + t.Fatalf("回填封面取回失败: %v / %q", err, data) + } +} + +// TestBackfillLocalCoverSkipsTXTAndCoveredBooks TXT 没有内嵌图片,已有封面的书 +// 也不该被重复处理。 +func TestBackfillLocalCoverSkipsTXTAndCoveredBooks(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + txt, err := svc.ImportLocalBook(ctx, "u1", "小说.txt", []byte(sampleTXT)) + if err != nil { + t.Fatalf("导入 TXT 失败: %v", err) + } + if svc.BackfillLocalCover(ctx, txt) { + t.Error("TXT 不该被回填封面") + } + + raw, _ := epubWithCoverFixture(t, true, false) + epub, err := svc.ImportLocalBook(ctx, "u1", "已带封面.epub", raw) + if err != nil { + t.Fatalf("导入 EPUB 失败: %v", err) + } + if svc.BackfillLocalCover(ctx, epub) { + t.Error("已有封面的书不该被回填") + } +} + +// TestImportLocalBookFromPath 服务器选书:原地引用不复制,章节可读, +// 移出书架不删除源文件。 +func TestImportLocalBookFromPath(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "服务器上的书.txt") + if err := os.WriteFile(srcPath, []byte(sampleTXT), 0o640); err != nil { + t.Fatal(err) + } + + book, err := svc.ImportLocalBookFromPath(ctx, "u1", srcPath) + if err != nil { + t.Fatalf("从服务器路径导入失败: %v", err) + } + if !book.LocalExternal { + t.Fatal("应标记为原地引用") + } + if book.LocalPath != srcPath { + t.Fatalf("LocalPath = %q,期望源文件路径 %q", book.LocalPath, srcPath) + } + if book.TotalChapterNum != 3 { + t.Fatalf("章节数 = %d,期望 3", book.TotalChapterNum) + } + if book.Name != "服务器上的书" { + t.Fatalf("书名 = %q", book.Name) + } + + content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) + if err != nil { + t.Fatalf("读正文失败: %v", err) + } + if !strings.Contains(content.Content, "第一章的正文内容。") { + t.Fatalf("正文不对: %q", content.Content) + } + + // 不应把文件复制进 data/reader/local + localDir, err := svc.localBooksDir() + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(localDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("原地引用不该产生托管副本,目录里有 %d 个文件", len(entries)) + } + + // 移出书架只解除引用,源文件必须还在 + if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil { + t.Fatalf("移出书架失败: %v", err) + } + if _, err := os.Stat(srcPath); err != nil { + t.Fatalf("原地引用的源文件被删除了: %v", err) + } +} + +// TestImportLocalBookFromPathRejectsUnsupported 只接受 TXT / EPUB,目录与超限文件要报错。 +func TestImportLocalBookFromPathRejectsUnsupported(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + dir := t.TempDir() + pdf := filepath.Join(dir, "book.pdf") + if err := os.WriteFile(pdf, []byte("%PDF-1.4"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := svc.ImportLocalBookFromPath(ctx, "u1", pdf); err == nil { + t.Fatal("PDF 不该被接受") + } + if _, err := svc.ImportLocalBookFromPath(ctx, "u1", dir); err == nil { + t.Fatal("目录不该被接受") + } + if _, err := svc.ImportLocalBookFromPath(ctx, "u1", filepath.Join(dir, "nope.txt")); err == nil { + t.Fatal("不存在的文件应报错") + } +} + +// TestImportLocalAudioDir 目录导入有声书:音频与 .strm 成为章节,按相对路径排序, +// 其它文件忽略;.strm 指向远端走媒体代理,指向本地文件走本地音频端点。 +func TestImportLocalAudioDir(t *testing.T) { + svc := newLocalBookService(t) + ctx := t.Context() + + base := t.TempDir() + dir := filepath.Join(base, "有声书") + shared := filepath.Join(base, "共享") + for _, d := range []string{filepath.Join(dir, "00-cd1"), shared} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + write := func(path, body string) { + if err := os.WriteFile(path, []byte(body), 0o640); err != nil { + t.Fatal(err) + } + } + // 相对路径排序:00-cd1/ 子目录在前,其后是同级文件 + write(filepath.Join(dir, "00-cd1", "01-正文.mp3"), "mp3-bytes-2") + write(filepath.Join(dir, "01-开场.mp3"), "mp3-bytes") + write(filepath.Join(dir, "02-远端.strm"), "https://cdn.example.com/a.mp3\n") + localTarget := filepath.Join(shared, "尾巴.flac") + write(localTarget, "flac-bytes") + write(filepath.Join(dir, "03-本地.strm"), localTarget+"\n") + // 非音频文件应被忽略 + write(filepath.Join(dir, "cover.jpg"), "jpg") + write(filepath.Join(dir, "readme.txt"), "txt") + + book, err := svc.ImportLocalAudioDir(ctx, "u1", dir) + if err != nil { + t.Fatalf("导入有声书目录失败: %v", err) + } + if book.Type != 1 { + t.Fatalf("Type = %d,期望 1(音频)", book.Type) + } + if !book.LocalExternal || book.LocalPath != dir { + t.Fatalf("应原地引用目录,得到 LocalPath=%q external=%v", book.LocalPath, book.LocalExternal) + } + if book.Name != "有声书" { + t.Fatalf("书名应取目录名,得到 %q", book.Name) + } + if book.TotalChapterNum != 4 { + t.Fatalf("章节数 = %d,期望 4(忽略 cover.jpg / readme.txt)", book.TotalChapterNum) + } + + chapters, err := svc.ListChapters(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + wantTitles := []string{"01-正文", "01-开场", "02-远端", "03-本地"} + for i, want := range wantTitles { + if chapters[i].Title != want { + t.Fatalf("第 %d 章标题 = %q,期望 %q(完整目录 %+v)", i, chapters[i].Title, want, chapters) + } + } + + // 本地音频文件 → 本地音频流端点 + first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) + if err != nil { + t.Fatalf("读第 2 章失败: %v", err) + } + if first.Type != "audio" || len(first.Tracks) != 1 { + t.Fatalf("第 2 章应为单轨音频: %+v", first) + } + if !strings.HasPrefix(first.Tracks[0], "/api/reader/local/audio?") { + t.Fatalf("本地音频应走本地流端点: %q", first.Tracks[0]) + } + + // .strm 指向远端 → 媒体代理 + remote, err := svc.LocalChapterContent(ctx, "u1", book.ID, 2) + if err != nil { + t.Fatalf("读第 3 章失败: %v", err) + } + if !strings.HasPrefix(remote.Tracks[0], "/api/reader/media?") { + t.Fatalf("远端 .strm 应走媒体代理: %q", remote.Tracks[0]) + } + + // .strm 指向本地文件 → 本地音频流端点 + local, err := svc.LocalChapterContent(ctx, "u1", book.ID, 3) + if err != nil { + t.Fatalf("读第 4 章失败: %v", err) + } + if !strings.HasPrefix(local.Tracks[0], "/api/reader/local/audio?") { + t.Fatalf("本地 .strm 应走本地流端点: %q", local.Tracks[0]) + } + u, err := url.Parse(local.Tracks[0]) + if err != nil { + t.Fatal(err) + } + resolved, err := svc.VerifyLocalAudioURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) + if err != nil { + t.Fatalf("音频签名校验失败: %v", err) + } + if resolved != localTarget { + t.Fatalf("解析出的音频路径 = %q,期望 %q", resolved, localTarget) + } +} + +// TestImportLocalAudioDirNoAudio 目录里没有音频时应给出明确错误。 +func TestImportLocalAudioDirNoAudio(t *testing.T) { + svc := newLocalBookService(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := svc.ImportLocalAudioDir(t.Context(), "u1", dir); err == nil { + t.Fatal("没有音频文件时应报错") + } +} + +// TestLocalAudioURLSignature 音频流地址的签名可校验、改一处即失效。 +func TestLocalAudioURLSignature(t *testing.T) { + svc := newLocalBookService(t) + path := filepath.Join("media", "有声书", "01.mp3") + + raw := svc.LocalAudioURL("book-1", path) + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(raw, "/api/reader/local/audio?b=book-1&p=") { + t.Fatalf("音频地址格式不对: %q", raw) + } + got, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), u.Query().Get("s")) + if err != nil || got != path { + t.Fatalf("签名校验失败: %v / %q", err, got) + } + if _, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), "deadbeef"); err == nil { + t.Fatal("错误签名应校验失败") + } + if _, err := svc.VerifyLocalAudioURL("book-2", u.Query().Get("p"), u.Query().Get("s")); err == nil { + t.Fatal("换一本书后签名应失效") + } +} + +// TestIsRemoteMediaURL 区分远端地址与本地路径。 +func TestIsRemoteMediaURL(t *testing.T) { + remote := []string{ + "https://cdn.example.com/a.mp3", + "http://x/y.flac", + "webdav://host/a.mp3", + "/api/strm/play/local/abc", + } + for _, raw := range remote { + if !isRemoteMediaURL(raw) { + t.Errorf("%q 应判为远端", raw) + } + } + local := []string{ + "本地音频.flac", + `D:\media\有声书\01.mp3`, + "sub/02.mp3", + } + for _, raw := range local { + if isRemoteMediaURL(raw) { + t.Errorf("%q 应判为本地路径", raw) + } + } +} + +// TestImportEPUBWithoutCover 没有封面图时不应硬凑,CoverURL 保持为空。 +func TestImportEPUBWithoutCover(t *testing.T) { + svc := newLocalBookService(t) + book, err := svc.ImportLocalBook(t.Context(), "u1", "无封面.epub", epubFixture(t)) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + if book.CoverURL != "" { + t.Fatalf("无封面书籍不该有 cover_url: %q", book.CoverURL) + } +} + +// TestImportEPUBCoverFallbackOnBrokenDeclaration 声明的封面 id 指不到条目时, +// 应退回“文件名像封面”的图片,而不是直接没有封面。 +func TestImportEPUBCoverFallbackOnBrokenDeclaration(t *testing.T) { + svc := newLocalBookService(t) + coverBytes := []byte("\xff\xd8\xff\xe0fallbackjpeg") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name, body string) { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + add("mimetype", "application/epub+zip") + add("META-INF/container.xml", ` + + +`) + // meta 指向一个 manifest 里不存在的 id + add("OEBPS/content.opf", ` + + + + + + + +`) + add("OEBPS/ch1.xhtml", `内容。`) + // 声明失效,只能靠文件名(含“封面”)兜底 + add("OEBPS/images/封面.png", string(coverBytes)) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + book, err := svc.ImportLocalBook(t.Context(), "u1", "兜底.epub", buf.Bytes()) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { + t.Fatalf("应兜底找到封面: %q", book.CoverURL) + } + u, err := url.Parse(book.CoverURL) + if err != nil { + t.Fatal(err) + } + entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) + if err != nil { + t.Fatalf("封面签名校验失败: %v", err) + } + if filepath.Base(entry) != "封面.png" { + t.Fatalf("兜底应命中封面.png,实际 %q", entry) + } + data, _, err := svc.ReadLocalAsset(t.Context(), book.ID, entry) + if err != nil || !bytes.Equal(data, coverBytes) { + t.Fatalf("读兜底封面失败: %v / %q", err, data) + } +} diff --git a/internal/service/reader/login.go b/internal/service/reader/login.go index b6c84f2..858b339 100644 --- a/internal/service/reader/login.go +++ b/internal/service/reader/login.go @@ -58,23 +58,27 @@ type SourceLoginInfo struct { // LoginResult 登录动作执行结果。 type LoginResult struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Toasts []string `json:"toasts,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Toasts []string `json:"toasts,omitempty"` Browsers []rule.BrowserRequest `json:"browsers,omitempty"` + // UIRefresh 书源通过 java.reLoginView / refreshExplore / upLoginData + // 要求重新渲染登录表单(前端据此重建 loginUi)。 + UIRefresh bool `json:"ui_refresh,omitempty"` // Values 执行后的登录信息(可能与执行前不同,如 checkStatus 回填邮箱)。 - Values map[string]string `json:"values"` - Cookies map[string]string `json:"cookies"` - LoggedIn bool `json:"logged_in"` + Values map[string]string `json:"values"` + Cookies map[string]string `json:"cookies"` + LoggedIn bool `json:"logged_in"` } // GetSourceLogin 返回书源登录界面描述与当前登录状态。 -func (s *ReaderService) GetSourceLogin(ctx context.Context, sourceID string) (*SourceLoginInfo, error) { +func (s *ReaderService) GetSourceLogin(ctx context.Context, userID, sourceID string) (*SourceLoginInfo, error) { src, bs, err := s.loadSource(ctx, sourceID) if err != nil { return nil, err } sess := s.newSession(ctx, src, bs) + sess.userID = userID defer sess.close() state := sess.state @@ -137,12 +141,16 @@ func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource, // action 为 loginUi 里某个控件的 action(如 "login(true)" / "checkStatus()"); // fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。 // action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。 -func (s *ReaderService) RunLoginAction(ctx context.Context, sourceID, action string, fields map[string]string) (*LoginResult, error) { +func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, action string, fields map[string]string) (*LoginResult, error) { src, bs, err := s.loadSource(ctx, sourceID) if err != nil { return nil, err } sess := s.newSession(ctx, src, bs) + sess.userID = userID + // 登录动作里才注入宿主浏览器:书源的「切换线路」「用户后台」等按钮 + // 依赖 java.startBrowserAwait 打开页面并等待用户操作。 + sess.browserEnabled = true defer sess.close() state := sess.state @@ -181,11 +189,12 @@ func (s *ReaderService) RunLoginAction(ctx context.Context, sourceID, action str state.flush() res := &LoginResult{ - OK: runErr == nil, - Toasts: state.toasts, - Browsers: state.browsers, - Values: values, - Cookies: state.snapshotCookies(), + OK: runErr == nil, + Toasts: state.toasts, + Browsers: state.browsers, + UIRefresh: state.UIRefreshRequested(), + Values: values, + Cookies: state.snapshotCookies(), } if runErr != nil { res.Error = runErr.Error() diff --git a/internal/service/reader/login_test.go b/internal/service/reader/login_test.go index 93eb4d9..b894946 100644 --- a/internal/service/reader/login_test.go +++ b/internal/service/reader/login_test.go @@ -21,6 +21,9 @@ import ( // 本文件:登录类书源的服务层链路测试。 // 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。 +// readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。 +const readerTestUserID = "test-user" + // loginTestServer 模拟一个需要登录的书源站点: // - POST /login_api 校验账号密码并下发会话 Cookie // - GET /search 读取 Cookie,无 Cookie 返回 401(模拟登录后才能搜索) @@ -155,7 +158,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { } // ── 登录 ── - res, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{ + res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{ "邮箱": "user@example.com", "密码": "pw123456", }) if err != nil { @@ -173,7 +176,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { // ── 登录态应落库(换一个 service 实例仍可读到)── svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos) - info, err := svc2.GetSourceLogin(ctx, sourceID) + info, err := svc2.GetSourceLogin(ctx, readerTestUserID, sourceID) if err != nil { t.Fatal(err) } @@ -200,7 +203,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { if err := svc.ClearSourceLogin(ctx, sourceID); err != nil { t.Fatal(err) } - info, err = svc.GetSourceLogin(ctx, sourceID) + info, err = svc.GetSourceLogin(ctx, readerTestUserID, sourceID) if err != nil { t.Fatal(err) } @@ -221,7 +224,7 @@ func TestSourceLoginWrongPassword(t *testing.T) { svc, _ := newLoginTestService(t) sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) - res, err := svc.RunLoginAction(t.Context(), sourceID, "", map[string]string{ + res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "", map[string]string{ "邮箱": "user@example.com", "密码": "wrong", }) if err != nil { @@ -245,7 +248,7 @@ func TestSourceLoginInfo_ExposesUIFields(t *testing.T) { svc, _ := newLoginTestService(t) sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) - info, err := svc.GetSourceLogin(t.Context(), sourceID) + info, err := svc.GetSourceLogin(t.Context(), readerTestUserID, sourceID) if err != nil { t.Fatal(err) } @@ -277,7 +280,7 @@ func TestSourceStateEncryptedAtRest(t *testing.T) { ctx := t.Context() sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) - if _, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{ + if _, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{ "邮箱": "user@example.com", "密码": "pw123456", }); err != nil { t.Fatal(err) @@ -313,7 +316,7 @@ func TestSourceVariableRoundTrip(t *testing.T) { if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil { t.Fatal(err) } - info, err := svc.GetSourceLogin(ctx, sourceID) + info, err := svc.GetSourceLogin(ctx, readerTestUserID, sourceID) if err != nil { t.Fatal(err) } diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 6a661d5..e1e134f 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -42,6 +42,11 @@ type ReaderService struct { repo *repository.ReaderRepository http *http.Client crypto *helper.SecretCipher + + // browserMu / browserPending 保护「待用户完成的页面」表 + // (java.startBrowser / startBrowserAwait,见 browser_panel.go)。 + browserMu sync.Mutex + browserPending map[string]*pendingBrowser } // NewReaderService 创建服务。 @@ -248,9 +253,18 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, if req.Body != "" { bodyReader = strings.NewReader(req.Body) } - target := req.URLNoQuery + // 请求目标是含 query 的完整 URL:对应 legado 的 `get(urlNoQuery, encodedQuery)` + // (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进 + // req.URL。早期这里误用 URLNoQuery,导致所有「参数写在 query 里」的 GET + // 请求都丢掉了参数(搜索关键词、分页等),下游站点拿到空参数直接返回空结果。 + target := req.URL if target == "" { - target = req.URL + target = req.URLNoQuery + } + // data: 地址是书源自带的「参数信封」,内容在本地,不发网络请求。 + // 对应 legado AnalyzeUrl.getByteArrayIfDataUri()。 + if raw, ok := rule.DecodeDataURI(target); ok { + return rule.EncodeRuleBody(req, raw, req.Charset), target, http.StatusOK, nil } if state != nil { // 登录请求头(除 Cookie 外)优先级低于书源显式配置,高于预设。 @@ -279,6 +293,10 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, for k, v := range req.Headers { httpReq.Header.Set(k, v) } + // Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压, + // 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。 + // 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。 + helper.StripAcceptEncoding(httpReq.Header) if req.Method == "POST" { switch { case req.IsForm: @@ -296,6 +314,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, if err != nil { return "", "", resp.StatusCode, err } + data = helper.DecompressBody(resp, data) + // 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type), + // 不做 charset 解码——书源会自己 hexDecodeToString 取回内容。 + if req.HexBody { + return hex.EncodeToString(data), resp.Request.URL.String(), resp.StatusCode, nil + } charset := req.Charset if charset == "" { charset = charsetFromContentType(resp.Header.Get("Content-Type")) @@ -361,6 +385,12 @@ type sourceSession struct { src *model.ReaderBookSource bs *BookSource state *sourceState + // userID 触发本次操作的用户(登录界面的浏览器待办按用户隔离)。 + userID string + // browserEnabled 是否给本次执行注入宿主浏览器。 + // 只在登录动作里开启:startBrowserAwait 会阻塞等待用户操作(可达十分钟), + // 若在搜索/正文等链路里被书源意外调用,会把普通请求长时间挂住。 + browserEnabled bool } // newSession 为指定书源建立执行上下文。 @@ -405,7 +435,8 @@ func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) } // loginCheckJs:书源借此检测会话失效并自行重登/重取(对应 legado WebBook.checkJs)。 // 返回新 body 时替换原响应,使上层规则直接拿到修复后的内容。 - if check := SPtr(sess.bs.LoginCheckJS); strings.TrimSpace(check) != "" { + // 声明了 type 的响应是原始字节的 hex(参数信封),不是可校验的文本,跳过。 + if check := SPtr(sess.bs.LoginCheckJS); strings.TrimSpace(check) != "" && !req.HexBody { body = sess.applyLoginCheck(check, body, code, finalURL) } return body, finalURL, code, nil @@ -446,8 +477,56 @@ func (sess *sourceSession) newAnalyzer(key string, page int, body, finalURL stri return ar } +// applyBookContext 把书籍/章节上下文注入解析器,供规则 JS 的 book / chapter 对象 +// 读取(对应 legado 里 AnalyzeRule 持有 Book / BookChapter 实体)。 +func (sess *sourceSession) applyBookContext(ar *rule.AnalyzeRule, bookURL string, book *model.ReaderBook, chapterTitle string, chapterIndex int) { + meta := map[string]any{"bookUrl": bookURL, "tocUrl": bookURL} + if book != nil { + meta["name"] = book.Name + meta["author"] = book.Author + meta["origin"] = book.Origin + meta["originName"] = book.OriginName + meta["kind"] = book.Kind + meta["coverUrl"] = book.CoverURL + meta["intro"] = book.Intro + meta["type"] = book.Type + meta["order"] = book.Order + meta["durChapterIndex"] = book.DurChapterIndex + meta["durChapterTitle"] = book.DurChapterTitle + meta["durChapterPos"] = book.DurChapterPos + ar.SetBookContext(book.Name, nil) + ar.SetBookCustomVars(parseBookVariableMap(book.Variable)) + } + ar.SetBookMeta(meta) + ar.SetChapterContext(chapterTitle, nil) + ar.SetChapterIndex(chapterIndex) +} + +// parseBookVariableMap 解析书架书籍的自定义变量 JSON(对应 legado Book.variableMap)。 +func parseBookVariableMap(raw string) map[string]string { + if strings.TrimSpace(raw) == "" { + return nil + } + var m map[string]string + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil + } + return m +} + // runner 构建本次请求的 JS 运行时(网络桥回 execute,携带书源上下文与会话状态)。 func (sess *sourceSession) runner(key string, page int) *rule.JSRunner { + // 宿主浏览器只在登录动作里注入(见 browserEnabled 的说明); + // 其它链路保持「无浏览器」语义,java.startBrowser* 会如实报错。 + var host rule.BrowserHost + if sess.browserEnabled { + host = &browserHost{ + svc: sess.svc, + sourceURL: sess.srcURL(), + sourceID: sess.srcID(), + userID: sess.userID, + } + } return rule.NewJSRunner(rule.JSConfig{ Fetch: func(req *rule.Request) (string, string, int, error) { return sess.fetch(req) @@ -464,9 +543,19 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner { Page: page, State: sess.state, JSLib: SPtr(sess.bs.JSLib), + Ctx: sess.ctx, + Browser: host, }) } +// srcID 书源记录 ID(登录界面待办按书源隔离)。 +func (sess *sourceSession) srcID() string { + if sess.src != nil { + return sess.src.ID + } + return "" +} + // srcURL 书源标识 URL(会话状态与 baseUrl 的键)。 func (sess *sourceSession) srcURL() string { if sess.src != nil && sess.src.SourceURL != "" { @@ -564,9 +653,11 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [ } var ( - mu sync.Mutex - hits []searchHit - skipped []SearchSkipped + mu sync.Mutex + // 初始化为空切片而不是 nil:nil 切片会被编码成 JSON null, + // 前端一旦按数组用(skipped.length)就直接 TypeError 崩页面。 + hits = []searchHit{} + skipped = []SearchSkipped{} ) g, gctx := errgroup.WithContext(ctx) g.SetLimit(searchConcurrency) @@ -789,28 +880,17 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo return nil, err } ar := sess.newAnalyzer("", 0, body, finalURL) + sess.applyBookContext(ar, bookURL, nil, "", 0) info := &BookInfo{BookURL: bookURL, TocURL: bookURL} if initRule := SPtr(bir.Init); initRule != "" { - // init 规则返回 JSON 对象时合并字段(对应 legado ruleBookInfo.init) - if initVal, err := ar.GetString(initRule, nil, false); err == nil && initVal != "" { - if strings.HasPrefix(strings.TrimSpace(initVal), "{") { - var m map[string]string - if json.Unmarshal([]byte(initVal), &m) == nil { - if v, ok := m["name"]; ok { - info.Name = v - } - if v, ok := m["author"]; ok { - info.Author = v - } - if v, ok := m["intro"]; ok { - info.Intro = v - } - if v, ok := m["tocUrl"]; ok { - info.TocURL = v - } - } - } + // 对应 legado BookInfo.analyzeBookInfo: + // analyzeRule.setContent(analyzeRule.getElement(infoRule.init)) + // init 的结果整体成为后续字段的解析内容。这个源的 init 是 + // `…$.data` 组合规则:先请求详情接口,再用 $.data 取出对象, + // 后面的 name / author / tocUrl 都在这个对象上求值。 + if initVal, err := ar.GetElement(initRule); err == nil && initVal != nil { + ar.SetContent(initVal, finalURL) } } if v, err := ar.GetString(SPtr(bir.Name), nil, false); err == nil && v != "" { @@ -849,19 +929,63 @@ type TocChapter struct { UpdateTime string `json:"update_time"` } -// GetToc 抓取目录。 -func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) { +// normalizeBookType 把 legado BookType 的位掩码归一成 MeBox 内部使用的 +// 0 文本 / 1 音频 / 2 图片 / 3 视频。 +// +// legado 的 BookType 是按位区分的常量(4=视频、8=文本、32=音频、64=图片), +// 书源在目录规则里直接给 book.type 赋这些值(听书源写 32 表示音频), +// 而 MeBox 的 ReaderBook.Type 沿用书源 bookSourceType 的 0/1/2/3 约定, +// 两者必须转换,否则音频书会被当成文本来渲染。 +func normalizeBookType(t int) int { + switch { + case t&32 != 0: + return 1 // audio + case t&64 != 0: + return 2 // image + case t&4 != 0: + return 3 // video + case t&8 != 0, t == 0: + return 0 // text + } + return 0 +} + +// GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型 +// (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。 +func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) { src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { return nil, err } - return s.getTocFrom(ctx, src, bs, bookURL, tocURL) + chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL) + if err != nil { + return nil, err + } + // 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。 + // 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。 + s.applyDeclaredBookType(ctx, userID, src.SourceURL, bookURL, declared) + return chapters, nil } -func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string) ([]TocChapter, error) { +// applyDeclaredBookType 把书源声明的书籍类型写回书架记录。 +func (s *ReaderService) applyDeclaredBookType(ctx context.Context, userID, origin, bookURL string, declared int) { + if declared < 0 || userID == "" || origin == "" || bookURL == "" { + return + } + book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL) + if err != nil || book == nil || book.Type == declared { + return + } + book.Type = declared + if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { + s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err)) + } +} + +func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string) ([]TocChapter, int, error) { tr := bs.RuleToc if tr == nil || SPtr(tr.ChapterList) == "" { - return nil, fmt.Errorf("书源未配置目录规则") + return nil, -1, fmt.Errorf("书源未配置目录规则") } if tocURL == "" { tocURL = bookURL @@ -871,21 +995,26 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou runner := sess.runner("", 0) req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner) if err != nil { - return nil, err + return nil, -1, err } if req.Unsupported != nil { - return nil, req.Unsupported + return nil, -1, req.Unsupported } sess.applyHeaders(req) body, finalURL, _, err := sess.fetch(req) if err != nil { - return nil, err + return nil, -1, err } ar := sess.newAnalyzer("", 0, body, finalURL) + sess.applyBookContext(ar, bookURL, nil, "", 0) elements, err := ar.GetElements(SPtr(tr.ChapterList)) if err != nil { - return nil, err + return nil, -1, err + } + declaredType := -1 + if t, ok := ar.BookTypeOverride(); ok { + declaredType = normalizeBookType(t) } var chapters []TocChapter for i, el := range elements { @@ -908,7 +1037,11 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou Index: i, Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime, }) } - return chapters, nil + // 章节规则可能逐条执行,取最后一次声明(书源是在元素循环前设置的)。 + if t, ok := ar.BookTypeOverride(); ok { + declaredType = normalizeBookType(t) + } + return chapters, declaredType, nil } // ChapterContent 章节内容(按类型返回文本/音频/图片)。 @@ -918,6 +1051,12 @@ type ChapterContent struct { Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理) Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理) ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle + // Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了), + // 首次播放需要等转码完成,之后命中缓存秒开。 + Transcoding bool `json:"transcoding,omitempty"` + // declaredType 书源在规则 JS 里声明的书籍类型(-1 表示未声明), + // 由 GetContentForBook 写回书架记录(对应 legado Book.type)。 + declaredType int } // ─── 媒体代理(音频流 / 漫画图片,带防盗链头与 HMAC 签名) ────────────────── @@ -960,6 +1099,10 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, for k, v := range helper.HTTPHeaderPresets() { httpReq.Header.Set(k, v) } + // 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给 + // 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩, + // 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。 + helper.StripAcceptEncoding(httpReq.Header) // 书源级请求头 if s.repo != nil { if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" { @@ -1033,15 +1176,21 @@ func GetAbsoluteURLOf(base *url.URL, ref string) string { } // GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。 +// 未指定书籍类型时按书源声明的类型判断(详情页的临时阅读路径)。 func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, bookURL, chapterURL string) (*ChapterContent, error) { src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { return nil, err } - return s.getContentFrom(ctx, src, bs, bookURL, chapterURL) + return s.getContentFrom(ctx, src, bs, bookURL, chapterURL, -1) } -func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, chapterURL string) (*ChapterContent, error) { +// getContentFrom 抓取正文。 +// +// bookType 是书架记录里的书籍类型(0文本/1音频/2图片),-1 表示未知、 +// 退回用书源的 bookSourceType。注意不能直接用 bookSourceType:文本型聚合源 +// 也会提供听书/漫画内容,真正的类型由书源在目录规则里声明(见 normalizeBookType)。 +func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, chapterURL string, bookType int) (*ChapterContent, error) { cr := bs.RuleContent if cr == nil || SPtr(cr.Content) == "" { return nil, fmt.Errorf("书源未配置正文规则") @@ -1049,6 +1198,9 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo var parts []string url := chapterURL lastFinalURL := "" + // 书源可以在规则 JS 里声明书籍类型(听书源会把 type 改成音频), + // 用最后一次声明为准;未声明时沿用书架记录里的类型。 + declaredType := -1 // 整章(含翻页)共用一个会话,翻页期间 Cookie/变量变更保持一致。 sess := s.newSession(ctx, src, bs) defer sess.close() @@ -1068,11 +1220,15 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo } lastFinalURL = finalURL ar := sess.newAnalyzer("", 0, body, finalURL) + sess.applyBookContext(ar, bookURL, nil, "", 0) list, err := ar.GetStringList(SPtr(cr.Content), nil, false) if err != nil { return nil, err } + if t, ok := ar.BookTypeOverride(); ok { + declaredType = normalizeBookType(t) + } parts = append(parts, strings.Join(list, "\n")) if SPtr(cr.NextContentURL) == "" { break @@ -1087,8 +1243,17 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo if rr := SPtr(cr.ReplaceRegex); rr != "" { content = rule.ApplyReplaceRegexString(content, rr) } - out := &ChapterContent{Content: content} - switch src.Type { + out := &ChapterContent{Content: content, declaredType: declaredType} + // 书源在规则 JS 里声明的类型优先(听书源会把书籍标成音频), + // 其次用书架记录里的类型,最后才退回书源的 bookSourceType。 + effective := bookType + if declaredType >= 0 { + effective = declaredType + } + if effective < 0 { + effective = src.Type + } + switch effective { case 1: // 音频:逐行地址,按最终页面 URL 绝对化 out.Type = "audio" for _, line := range splitURLLines(content) { @@ -1151,11 +1316,37 @@ func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBo // ListBooks 书架列表。 func (s *ReaderService) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) { - return s.repo.ListBooks(ctx, userID) + books, err := s.repo.ListBooks(ctx, userID) + if err != nil { + return nil, err + } + // 网络书籍的 total_chapter_num 不落库,用已缓存的目录条数补齐,供书架显示未读章数。 + // 统计失败不影响书架列表本身。 + ids := make([]string, 0, len(books)) + for i := range books { + ids = append(ids, books[i].ID) + } + if counts, err := s.repo.CountChaptersByBook(ctx, ids); err == nil { + for i := range books { + if n := counts[books[i].ID]; n > books[i].TotalChapterNum { + books[i].TotalChapterNum = n + } + } + } + for i := range books { + books[i].IsLocal = books[i].LocalPath != "" + // 早期导入的本地 EPUB 没存封面,这里按需自愈一次 + s.BackfillLocalCover(ctx, &books[i]) + } + return books, nil } -// RemoveBook 移出书架。 +// RemoveBook 移出书架(本地书籍顺带删掉落盘的正文文件)。 func (s *ReaderService) RemoveBook(ctx context.Context, userID, id string) error { + book, err := s.repo.GetBook(ctx, id) + if err == nil && book.UserID == userID { + s.DeleteLocalBookFile(book) + } return s.repo.DeleteBook(ctx, userID, id) } @@ -1175,6 +1366,23 @@ func (s *ReaderService) SaveProgress(ctx context.Context, userID, bookID string, return s.repo.UpdateBook(ctx, book) } +// SaveAudioConfig 保存听书跳过片头/片尾设置(对应 legado Book.openCredits/closeCredits,单位秒)。 +func (s *ReaderService) SaveAudioConfig(ctx context.Context, userID, bookID string, openCredits, closeCredits int) error { + if openCredits < 0 || closeCredits < 0 { + return fmt.Errorf("片头/片尾秒数不能为负") + } + book, err := s.repo.GetBook(ctx, bookID) + if err != nil { + return err + } + if book.UserID != userID { + return fmt.Errorf("无权操作他人书架") + } + book.OpenCredits = openCredits + book.CloseCredits = closeCredits + return s.repo.UpdateBook(ctx, book) +} + // ListChapters 目录缓存读取。 func (s *ReaderService) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) { return s.repo.ListChapters(ctx, bookID) @@ -1203,6 +1411,49 @@ func (s *ReaderService) SaveChapters(ctx context.Context, bookID string, chapter return s.repo.ReplaceChapters(ctx, bookID, models) } +// WarmUpBookChaptersAsync 异步补一次目录缓存,不阻塞加入书架接口。 +// 加入书架时只写书籍记录、不抓目录,书架就没有总章数可算未读;这里后台抓一次目录落库。 +func (s *ReaderService) WarmUpBookChaptersAsync(ctx context.Context, userID string, book *model.ReaderBook) { + if book == nil { + return + } + // 请求结束后 gin 的 ctx 会被取消,先摘掉取消信号再开 goroutine。 + bg := context.WithoutCancel(ctx) + go func() { + warmCtx, cancel := context.WithTimeout(bg, 60*time.Second) + defer cancel() + s.WarmUpBookChapters(warmCtx, userID, book) + }() +} + +// WarmUpBookChapters 抓取目录并写入章节缓存,供书架显示未读章数。 +// 本地书籍、已有目录缓存、书源未配目录规则的情况都会直接跳过,失败只记日志。 +func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, book *model.ReaderBook) { + if book == nil || book.LocalPath != "" { + return + } + if existing, err := s.repo.ListChapters(ctx, book.ID); err == nil && len(existing) > 0 { + return + } + chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL) + if err != nil { + if s.log != nil { + s.log.Debug("reader: 预热目录失败", zap.String("book", book.ID), zap.Error(err)) + } + return + } + if len(chapters) == 0 { + return + } + inputs := make([]ChapterInput, 0, len(chapters)) + for _, ch := range chapters { + inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume}) + } + if err := s.SaveChapters(ctx, book.ID, inputs); err != nil && s.log != nil { + s.log.Warn("reader: 预热目录写入失败", zap.String("book", book.ID), zap.Error(err)) + } +} + // ListReplaceRules 用户替换规则列表。 func (s *ReaderService) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) { return s.repo.ListReplaceRules(ctx, userID) @@ -1295,6 +1546,10 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st if err != nil { return nil, err } + // 本地导入书籍:正文直接从本地文件读,不走书源 + if book.LocalPath != "" { + return s.LocalChapterContent(ctx, userID, bookID, chapterIndex) + } chapters, err := s.repo.ListChapters(ctx, bookID) if err != nil { return nil, err @@ -1306,10 +1561,22 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st return nil, fmt.Errorf("章节序号越界(共 %d 章)", len(chapters)) } ch := chapters[chapterIndex] - out, err := s.GetContent(ctx, "", book.Origin, book.BookURL, ch.URL) + src, bs, err := s.loadSourceFlexible(ctx, "", book.Origin) if err != nil { return nil, err } + out, err := s.getContentFrom(ctx, src, bs, book.BookURL, ch.URL, book.Type) + if err != nil { + return nil, err + } + // 书源在规则 JS 里声明的书籍类型写回书架记录:听书/漫画/短剧源靠它 + // 声明类型,否则下次阅读又会按导入时的默认类型(文本)渲染。 + if out.declaredType >= 0 && out.declaredType != book.Type { + book.Type = out.declaredType + if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { + s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err)) + } + } if out.Type == "text" { out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content) } @@ -1426,7 +1693,7 @@ func (s *ReaderService) SmokeChain(ctx context.Context, sourceID string, src *mo logf("info", "info", "书名: %s 作者: %s 最新章节: %s", info.Name, info.Author, info.LatestChapter) logf("toc", "info", "访问目录页: %s", info.TocURL) - chapters, err := s.getTocFrom(ctx, src, bs, first.BookURL, info.TocURL) + chapters, _, err := s.getTocFrom(ctx, src, bs, first.BookURL, info.TocURL) if err != nil { return fail("toc", err) } @@ -1444,7 +1711,7 @@ func (s *ReaderService) SmokeChain(ctx context.Context, sourceID string, src *mo continue } logf("content", "info", "访问正文: %s", c.URL) - content, err := s.getContentFrom(ctx, src, bs, first.BookURL, c.URL) + content, err := s.getContentFrom(ctx, src, bs, first.BookURL, c.URL, -1) if err != nil { return fail("content", err) } diff --git a/internal/service/reader/rule/analyzer.go b/internal/service/reader/rule/analyzer.go index 242e0bc..20d1111 100644 --- a/internal/service/reader/rule/analyzer.go +++ b/internal/service/reader/rule/analyzer.go @@ -26,9 +26,18 @@ type AnalyzeRule struct { bookVars map[string]string vars map[string]string chapterTitle string + chapterIndex int bookName string - sourceGetter func(key string) string - sourcePutter func(key, value string) + // bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。 + bookMeta map[string]any + // bookCustom 书籍自定义变量(对应 legado Book.variableMap), + // 由规则 JS 的 book.getVariable / book.putVariable 读写。 + bookCustom map[string]string + // bookTypeOverride 书源在规则 JS 里给 book.type 赋的值 + // (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。 + bookTypeOverride *int + sourceGetter func(key string) string + sourcePutter func(key, value string) ruleCache map[string][]*SourceRule } @@ -85,6 +94,57 @@ func (a *AnalyzeRule) SetChapterContext(title string, vars map[string]string) { } } +// SetChapterIndex 设置当前章节下标(规则 JS 的 chapter.index)。 +func (a *AnalyzeRule) SetChapterIndex(i int) { a.chapterIndex = i } + +// SetBookMeta 注入书籍元数据(legado 的 Book 实体字段), +// 供规则 JS 里的 `book` 对象读取(name/author/coverUrl/durChapterIndex…)。 +func (a *AnalyzeRule) SetBookMeta(meta map[string]any) { + if len(meta) == 0 { + return + } + if a.bookMeta == nil { + a.bookMeta = make(map[string]any, len(meta)) + } + for k, v := range meta { + a.bookMeta[k] = v + } +} + +// SetBookCustomVars 注入书籍自定义变量(对应 legado Book.variableMap), +// 规则 JS 通过 book.getVariable / book.putVariable 读写。 +func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) { + if vars != nil { + a.bookCustom = vars + } +} + +// SetBookType 记录书源声明的书籍类型(legado Book.type)。 +func (a *AnalyzeRule) SetBookType(t int) { + v := t + a.bookTypeOverride = &v +} + +// BookTypeOverride 返回书源在规则 JS 里声明的书籍类型; +// ok 为 false 表示书源没有声明(应沿用书架记录里的类型)。 +func (a *AnalyzeRule) BookTypeOverride() (int, bool) { + if a.bookTypeOverride == nil { + return 0, false + } + return *a.bookTypeOverride, true +} + +// bookTypeValue 供 book.type 读取:优先书源本次声明的值,否则用元数据里的。 +func (a *AnalyzeRule) bookTypeValue() any { + if a.bookTypeOverride != nil { + return *a.bookTypeOverride + } + if v, ok := a.bookMeta["type"]; ok { + return v + } + return 0 +} + // SetBookContext 设置书籍上下文(name 与书籍级变量存储)。 func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) { a.bookName = name diff --git a/internal/service/reader/rule/bridge.go b/internal/service/reader/rule/bridge.go index 4d7a21f..23de256 100644 --- a/internal/service/reader/rule/bridge.go +++ b/internal/service/reader/rule/bridge.go @@ -410,7 +410,13 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { bindSourceState(vm, set, r.state, r.cfg.SourceProps) } - // ── 宿主交互:服务端无 UI,转为可回传前端的提示 / 待打开链接 ── + // ── 宿主交互:提示、浏览器页面、登录界面刷新 ── + // + // legado 用内置 WebView 承载页面:startBrowser / startBrowserAwait 打开一个 + // 页面让用户完成防爬校验、登录或参数选择,Await 版本还会把用户操作后的 + // 页面源码作为 StrResponse 返回。真实的聚合类书源(如光遇聚合)把「线路 + // 切换」「用户后台」「书源设置」全部建在这两个函数上,因此这里必须真正 + // 把页面交给前端,而不是记录个地址就算完。 if r.state != nil { toast := func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { @@ -420,17 +426,59 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { } set("toast", toast) set("longToast", toast) - // startBrowser(url, title):记录待打开地址,前端可代开新标签。 + + // startBrowser(url, title[, html]):展示页面,不等待用户完成。 set("startBrowser", func(call goja.FunctionCall) goja.Value { - r.state.OpenBrowser(stringArg(call, 0), stringArgOr(call, 1, "")) + if err := r.openBrowser(parseBrowserArgs(call)); err != nil { + bridgeErr("startBrowser", err) + } return goja.Null() }) - // startBrowserAwait:服务端无 WebView,无法等待人工校验。 - // 记录地址后抛出明确错误,避免书源逻辑误把空 body 当成功。 + // showBrowser(url, html, preloadJs, config):legado 中同样是「打开即返回」 + // 的对话框,只是参数含义不同,这里统一按展示处理。 + set("showBrowser", func(call goja.FunctionCall) goja.Value { + req := parseBrowserArgs(call) + if req.Title == "" { + req.Title = stringArgOr(call, 2, "") + } + if err := r.openBrowser(req); err != nil { + bridgeErr("showBrowser", err) + } + return goja.Null() + }) + // startBrowserAwait(url, title[, refetchAfterSuccess][, html]): + // 展示页面并阻塞等待用户完成后回传页面内容(对应 legado StrResponse)。 set("startBrowserAwait", func(call goja.FunctionCall) goja.Value { - url := stringArg(call, 0) - r.state.OpenBrowser(url, stringArgOr(call, 1, "")) - panic(vm.ToValue("java.startBrowserAwait: 服务端无浏览器,需要人工操作的页面请手动打开:" + url)) + req := parseBrowserArgs(call) + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + if _, isStr := call.Arguments[2].Export().(string); !isStr { + req.Refetch = call.Arguments[2].ToBoolean() + } + } + res, err := r.awaitBrowser(req) + if err != nil { + bridgeErr("startBrowserAwait", err) + } + finalURL := res.URL + if finalURL == "" { + finalURL = req.URL + } + return newResponseObject(vm, res.Body, 200, finalURL, nil) + }) + + // reLoginView / refreshExplore:请求宿主重新渲染登录表单。 + // MeBox 每次登录动作后都会重新拉取 loginUi 并重建表单,因此这是真实 + // 生效的信号,而不是静默空实现。 + for _, name := range []string{"reLoginView", "refreshExplore"} { + set(name, func(call goja.FunctionCall) goja.Value { + r.requestUIRefresh() + return goja.Null() + }) + } + // upLoginData(data):书源把服务端返回的值回填进登录表单。 + set("upLoginData", func(call goja.FunctionCall) goja.Value { + r.applyLoginData(call.Arguments) + return goja.Null() }) } // 设备标识:部分源用 deviceID/androidId 做"是否支持该环境"探测, @@ -440,10 +488,13 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { panic(vm.ToValue("java." + name + ": 服务端无设备标识")) }) } - // 刷新发现页 / 打开界面:纯 UI 动作,服务端空实现。 - for _, name := range []string{"refreshExplore", "open", "showBrowser", "reLoginView", "qread"} { - set(name, func(call goja.FunctionCall) goja.Value { return goja.Null() }) - } + // 说明:下面这些名字在服务端刻意不定义,调用即抛异常,以对齐 legado 的 + // 真实可用面,避免「静默空实现」让书源误判运行环境: + // qread / showReadingBrowser / startBrowserDp —— legado 中并不存在 + // (例如 checkEnv 用 java.qread() 探测「轻阅」,旧实现返回成功会让书源 + // 误认为运行在轻阅里,从而跳过它自己的降级分支) + // open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页), + // 服务端没有对应页面,谎报成功会让书源走错分支 // 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持 unsupported := func(name string) func(goja.FunctionCall) goja.Value { diff --git a/internal/service/reader/rule/browser.go b/internal/service/reader/rule/browser.go new file mode 100644 index 0000000..48af819 --- /dev/null +++ b/internal/service/reader/rule/browser.go @@ -0,0 +1,227 @@ +package rule + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// 本文件:书源 JS 的宿主浏览器能力契约。 +// +// legado 用内置 WebView 实现 java.startBrowser / java.startBrowserAwait: +// 打开一个页面让用户完成防爬校验、登录或参数选择,并(Await 版本)把用户 +// 操作后的页面源码作为 StrResponse 返回给书源解析。真实书源把整套「线路 +// 切换」「用户后台」「书源设置」都建在这两个函数上,因此服务端必须提供 +// 等价能力,否则这些按钮只会走到书源自己的 catch 分支。 +// +// MeBox 没有 WebView,改由服务层把页面交给前端网页承载: +// - AwaitBrowser:阻塞等待前端回传 DOM(对应 startBrowserAwait) +// - OpenBrowser: 只展示不等待(对应 startBrowser / showBrowser) + +// BrowserTask 一个需要用户在前端页面中完成的请求。 +type BrowserTask struct { + // URL 书源传入的原始地址,可能是 http(s),也可能是 data:text/html;base64,…。 + URL string + // Title 页面标题(对应 legado 对话框标题)。 + Title string + // HTML URL 为 data: 系地址时解析出的页面源码,空表示由服务层按 URL 抓取。 + HTML string + // Refetch 对应 legado startBrowserAwait 的 refetchAfterSuccess: + // 用户确认后重新抓取该 URL 作为返回内容,而不是回传页面 DOM。 + Refetch bool +} + +// BrowserResult 用户在页面上完成操作后的回传。 +type BrowserResult struct { + // URL 最终地址(对应 StrResponse.url())。 + URL string + // Body 页面内容:默认是用户操作后的 DOM 序列化, + // Refetch 为真时是服务端重新抓取的响应体。 + Body string +} + +// BrowserHost 由服务层注入的宿主浏览器实现。 +type BrowserHost interface { + // AwaitBrowser 展示页面并阻塞等待用户回传(java.startBrowserAwait)。 + AwaitBrowser(ctx context.Context, req BrowserTask) (BrowserResult, error) + // OpenBrowser 展示页面但不等待(java.startBrowser / java.showBrowser)。 + OpenBrowser(ctx context.Context, req BrowserTask) error +} + +// ErrBrowserCancelled 用户在页面里主动取消。 +var ErrBrowserCancelled = errBrowserCancelled{} + +type errBrowserCancelled struct{} + +func (errBrowserCancelled) Error() string { return "用户取消了页面操作" } + +// ─── 运行时侧:把页面交给宿主浏览器 ──────────────────────────────────────── + +// parseBrowserArgs 解析 startBrowser / startBrowserAwait 的位置参数。 +// +// startBrowser(url, title[, html]) +// startBrowserAwait(url, title[, refetchAfterSuccess][, html]) +// +// 两者第 3 参类型不同(html 字符串 vs boolean),按实际类型判定。 +func parseBrowserArgs(call goja.FunctionCall) BrowserTask { + req := BrowserTask{ + URL: stringArg(call, 0), + Title: stringArgOr(call, 1, ""), + } + req.HTML = ParseDataHTML(req.URL) + // 第 3 / 第 4 参里的字符串按 html 处理(data: URL 之外显式传入的页面源码)。 + for _, i := range []int{2, 3} { + if len(call.Arguments) <= i { + continue + } + arg := call.Arguments[i] + if goja.IsUndefined(arg) || goja.IsNull(arg) { + continue + } + if s, ok := arg.Export().(string); ok && strings.TrimSpace(s) != "" { + req.HTML = s + } + } + return req +} + +// openBrowser 展示页面但不等待。 +// +// 注入了宿主浏览器时交给宿主(前端可交互的页面面板);未注入时退化为 +// 记录地址供前端代开,不中断书源逻辑。 +func (r *JSRunner) openBrowser(req BrowserTask) error { + if req.URL == "" && req.HTML == "" { + return nil + } + if r.cfg.Browser != nil { + return r.cfg.Browser.OpenBrowser(r.ctx(), req) + } + if r.state != nil { + r.state.OpenBrowser(req.URL, req.Title) + } + return nil +} + +// awaitBrowser 展示页面并阻塞等待用户完成。 +// +// 等待期间暂停 JS 超时计时:用户可能要看很久,默认 10s 的执行预算不能 +// 用在「等人」上。 +func (r *JSRunner) awaitBrowser(req BrowserTask) (BrowserResult, error) { + if req.URL == "" && req.HTML == "" { + return BrowserResult{}, fmt.Errorf("startBrowserAwait: 缺少页面地址") + } + if r.cfg.Browser == nil { + if r.state != nil { + r.state.OpenBrowser(req.URL, req.Title) + } + return BrowserResult{}, fmt.Errorf("服务端未提供浏览器能力,需要人工操作的页面请手动打开") + } + resume := r.pauseTimeout() + defer resume() + return r.cfg.Browser.AwaitBrowser(r.ctx(), req) +} + +// ctx 返回本次执行的上下文(未注入时用 Background)。 +func (r *JSRunner) ctx() context.Context { + if r.cfg.Ctx != nil { + return r.cfg.Ctx + } + return context.Background() +} + +// requestUIRefresh 请求宿主重新渲染登录表单(java.reLoginView / refreshExplore)。 +func (r *JSRunner) requestUIRefresh() { + if ui, ok := r.state.(UIState); ok { + ui.RequestUIRefresh() + } +} + +// applyLoginData 把 java.upLoginData 传入的值合并进登录表单。 +func (r *JSRunner) applyLoginData(args []goja.Value) { + if len(args) == 0 || goja.IsUndefined(args[0]) || goja.IsNull(args[0]) { + return + } + ui, ok := r.state.(UIState) + if !ok { + return + } + exported, ok := args[0].Export().(map[string]any) + if !ok { + return + } + data := make(map[string]string, len(exported)) + for k, v := range exported { + data[k] = anyToStringish(v) + } + ui.ApplyLoginData(data) +} + +func anyToStringish(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case bool: + if t { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", v) + } +} + +// ParseDataHTML 解析 data:text/html 系地址,返回内嵌的页面源码。 +// 支持 base64 与百分号编码两种形式;非 data 地址或解码失败返回空串。 +func ParseDataHTML(rawURL string) string { + s := strings.TrimSpace(rawURL) + if !strings.HasPrefix(s, "data:") { + return "" + } + comma := strings.Index(s, ",") + if comma < 0 { + return "" + } + meta := s[len("data:"):comma] + payload := s[comma+1:] + // MIME 限定在 text/*(书源也可能用 text/plain 塞 HTML) + mime := meta + if i := strings.Index(mime, ";"); i >= 0 { + mime = mime[:i] + } + if mime != "" && !strings.HasPrefix(mime, "text/") { + return "" + } + if strings.Contains(strings.ToLower(meta), ";base64") { + // base64 可能带换行;同时兼容 URL-safe 变体 + cleaned := strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', ' ', '\t': + return -1 + } + return r + }, payload) + if b, err := base64.StdEncoding.DecodeString(cleaned); err == nil { + return string(b) + } + if b, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil { + return string(b) + } + if b, err := base64.URLEncoding.DecodeString(cleaned); err == nil { + return string(b) + } + if b, err := base64.RawURLEncoding.DecodeString(cleaned); err == nil { + return string(b) + } + return "" + } + if dec, err := url.PathUnescape(payload); err == nil { + return dec + } + return "" +} diff --git a/internal/service/reader/rule/browser_test.go b/internal/service/reader/rule/browser_test.go new file mode 100644 index 0000000..106abc0 --- /dev/null +++ b/internal/service/reader/rule/browser_test.go @@ -0,0 +1,305 @@ +package rule + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "sync" + "testing" + "time" +) + +// 本文件:书源宿主浏览器(java.startBrowser / startBrowserAwait)的运行时测试。 +// +// 这些用例对应真实聚合类书源的「切换线路」「用户后台」路径: +// 书源把页面交给宿主,宿主回传用户操作后的页面源码,书源再从 DOM 里解析结果。 + +// fakeBrowserHost 记录宿主调用并按用例给定的函数回传结果。 +type fakeBrowserHost struct { + mu sync.Mutex + awaitCalls []BrowserTask + openCalls []BrowserTask + respond func(req BrowserTask) (BrowserResult, error) + openErr error +} + +func (f *fakeBrowserHost) AwaitBrowser(_ context.Context, req BrowserTask) (BrowserResult, error) { + f.mu.Lock() + f.awaitCalls = append(f.awaitCalls, req) + respond := f.respond + f.mu.Unlock() + if respond == nil { + return BrowserResult{URL: req.URL, Body: ""}, nil + } + return respond(req) +} + +func (f *fakeBrowserHost) OpenBrowser(_ context.Context, req BrowserTask) error { + f.mu.Lock() + defer f.mu.Unlock() + f.openCalls = append(f.openCalls, req) + return f.openErr +} + +// newBrowserRunner 用聚合源固件构建运行时,并注入宿主浏览器。 +func newBrowserRunner(t *testing.T, state SourceState, host BrowserHost, timeout time.Duration) *JSRunner { + t.Helper() + props := sampleSourceProps(t) + r := NewJSRunner(JSConfig{ + SourceProps: props, + JSLib: props["jsLib"].(string), + State: state, + BaseURL: "https://v1.example-aggregate.com", + Browser: host, + Timeout: timeout, + Fetch: func(req *Request) (string, string, int, error) { + return `{}`, req.URL, 200, nil + }, + }) + if err := r.JSLibErr(); err != nil { + t.Fatalf("jsLib 执行失败: %v", err) + } + return r +} + +// TestParseDataHTML 内嵌页面地址应被解码(base64 与百分号编码两种形式)。 +func TestParseDataHTML(t *testing.T) { + page := "线路" + cases := map[string]string{ + "base64": "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(page)), + "charset+base64": "data:text/html;charset=utf-8;base64," + + base64.StdEncoding.EncodeToString([]byte(page)), + "urlencoded": "data:text/html," + "%3Chtml%3E%3Cbody%3E%23%3C%2Fbody%3E%3C%2Fhtml%3E", + } + for name, raw := range cases { + got := ParseDataHTML(raw) + if got == "" { + t.Fatalf("%s: 未解码出页面内容", name) + } + } + if got := ParseDataHTML("https://example.com/a"); got != "" { + t.Fatalf("非 data 地址不应解码,实际 %q", got) + } + if got := ParseDataHTML("data:image/png;base64,AAAA"); got != "" { + t.Fatalf("非文本 MIME 不应解码,实际 %q", got) + } + // base64 带换行(书源拼接长 HTML 时常见) + wrapped := "data:text/html;base64," + wrapBase64(base64.StdEncoding.EncodeToString([]byte(page))) + if got := ParseDataHTML(wrapped); got != page { + t.Fatalf("带换行的 base64 解码失败: %q", got) + } +} + +func wrapBase64(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && i%40 == 0 { + b.WriteByte('\n') + } + b.WriteRune(r) + } + return b.String() +} + +// TestSwitchLineViaStartBrowserAwait 核心回归:书源用 startBrowserAwait 打开 +// 内嵌页面,宿主回传用户操作后的 DOM,书源据此切换线路。 +func TestSwitchLineViaStartBrowserAwait(t *testing.T) { + state := NewMemoryState() + host := &fakeBrowserHost{} + host.respond = func(req BrowserTask) (BrowserResult, error) { + // 宿主应当已经把 data: 地址解码成可承载的页面源码 + if !strings.Contains(req.HTML, `id="serverValue"`) { + t.Errorf("宿主未收到解码后的页面: %q", req.HTML) + } + if req.Title != "线路设置" { + t.Errorf("标题 = %q", req.Title) + } + if req.Refetch { + t.Error("第 3 个参数为 false 时不应要求重新抓取") + } + // 模拟用户在页面里选了 v2 线路后回传的 DOM + return BrowserResult{ + URL: req.URL, + Body: `https://v2.example-aggregate.com`, + }, nil + } + r := newBrowserRunner(t, state, host, 5*time.Second) + loginJS := loadTestdata(t, "sample_loginUrl.js") + + v, err := r.EvalAction(loginJS+"\nswitchLine()", nil) + if err != nil { + t.Fatalf("switchLine 执行失败: %v", err) + } + if got := anyToString(v); got != "https://v2.example-aggregate.com" { + t.Fatalf("switchLine 返回 %q", got) + } + // 书源应把解析出的线路写进源变量,并让 BaseUrl 跟随 + if got := state.GetVariable(); !strings.Contains(got, "v2.example-aggregate.com") { + t.Fatalf("线路未写入源变量: %q", got) + } + base, err := r.EvalAction(`BaseUrl()`, nil) + if err != nil { + t.Fatal(err) + } + if got := anyToString(base); got != "https://v2.example-aggregate.com" { + t.Fatalf("BaseUrl 未跟随线路: %q", got) + } + if len(host.awaitCalls) != 1 { + t.Fatalf("宿主 await 调用次数 = %d", len(host.awaitCalls)) + } +} + +// TestStartBrowserAwaitPausesJSTimeout 等待人工操作期间不应被 JS 执行超时打断。 +// 宿主故意比 Timeout 慢,若看门狗没有暂停,goja 会在恢复执行时抛出超时异常。 +func TestStartBrowserAwaitPausesJSTimeout(t *testing.T) { + state := NewMemoryState() + host := &fakeBrowserHost{} + host.respond = func(req BrowserTask) (BrowserResult, error) { + // 模拟用户慢慢点选:远超 Timeout + time.Sleep(300 * time.Millisecond) + return BrowserResult{URL: req.URL, Body: "ok"}, nil + } + r := newBrowserRunner(t, state, host, 80*time.Millisecond) + loginJS := loadTestdata(t, "sample_loginUrl.js") + + if _, err := r.EvalAction(loginJS+"\nswitchLine()", nil); err != nil { + t.Fatalf("等待人工操作期间被误判为 JS 超时: %v", err) + } +} + +// TestStartBrowserAwaitCancel 用户取消页面时应把异常抛回书源, +// 书源走自己的降级分支(提示语),而不是拿到空 body 当成成功。 +func TestStartBrowserAwaitCancel(t *testing.T) { + state := NewMemoryState() + host := &fakeBrowserHost{} + host.respond = func(BrowserTask) (BrowserResult, error) { + return BrowserResult{}, ErrBrowserCancelled + } + r := newBrowserRunner(t, state, host, 5*time.Second) + loginJS := loadTestdata(t, "sample_loginUrl.js") + + _, err := r.EvalAction(loginJS+"\nswitchLine()", nil) + if err == nil { + t.Fatal("取消后应把异常抛回书源") + } + if !strings.Contains(err.Error(), "取消") { + t.Fatalf("错误信息未说明取消: %v", err) + } +} + +// TestUserBackendUsesAwait 用户后台按钮同样走 startBrowserAwait(普通 http 地址)。 +func TestUserBackendUsesAwait(t *testing.T) { + state := NewMemoryState() + state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn") + host := &fakeBrowserHost{} + host.respond = func(req BrowserTask) (BrowserResult, error) { + if !strings.HasSuffix(req.URL, "/user") { + t.Errorf("用户后台地址 = %q", req.URL) + } + return BrowserResult{URL: req.URL, Body: "用户后台"}, nil + } + r := newBrowserRunner(t, state, host, 5*time.Second) + loginJS := loadTestdata(t, "sample_loginUrl.js") + + if _, err := r.EvalAction(loginJS+"\nuser()", nil); err != nil { + t.Fatalf("user() 执行失败: %v", err) + } + if len(host.awaitCalls) != 1 { + t.Fatalf("应为用户后台打开页面,await 调用 = %d", len(host.awaitCalls)) + } +} + +// TestStartBrowserDoesNotWait startBrowser 只展示,不阻塞也不回传。 +func TestStartBrowserDoesNotWait(t *testing.T) { + state := NewMemoryState() + host := &fakeBrowserHost{} + r := newBrowserRunner(t, state, host, 5*time.Second) + + if _, err := r.EvalAction(`java.startBrowser('https://vip.example.com', '光遇看书')`, nil); err != nil { + t.Fatalf("startBrowser 失败: %v", err) + } + if len(host.openCalls) != 1 || len(host.awaitCalls) != 0 { + t.Fatalf("startBrowser 应只展示不等待: open=%d await=%d", len(host.openCalls), len(host.awaitCalls)) + } + if host.openCalls[0].URL != "https://vip.example.com" { + t.Fatalf("展示地址 = %q", host.openCalls[0].URL) + } +} + +// TestShowBrowserOpensDialog showBrowser 与 startBrowser 一样是「打开即返回」。 +func TestShowBrowserOpensDialog(t *testing.T) { + state := NewMemoryState() + host := &fakeBrowserHost{} + r := newBrowserRunner(t, state, host, 5*time.Second) + + if _, err := r.EvalAction(`java.showBrowser('https://example.com/settings', '', '光遇书源设置', '')`, nil); err != nil { + t.Fatalf("showBrowser 失败: %v", err) + } + if len(host.openCalls) != 1 { + t.Fatalf("showBrowser 应展示页面: %d", len(host.openCalls)) + } +} + +// TestEnvProbeAPIsThrow 服务端不具备的 UI 函数必须抛异常,而不是静默成功。 +// +// 这条对真实书源很关键:聚合源用 checkEnv() 探测运行环境,其中 +// try { java.qread(); return "轻阅"; } catch (e) {} +// 旧实现把 qread 做成「返回 null 的空操作」,于是探测结果变成「轻阅」, +// 书源据此跳过自己的降级分支,表现为「按钮点了没反应」。 +func TestEnvProbeAPIsThrow(t *testing.T) { + r := NewJSRunner(JSConfig{State: NewMemoryState()}) + for _, name := range []string{"qread", "showReadingBrowser", "startBrowserDp", "open", "searchBook"} { + if _, err := r.EvalAction("java."+name+"()", nil); err == nil { + t.Fatalf("java.%s 应当抛异常(服务端无此能力)", name) + } + } + // checkEnv 结构:qread 抛异常后应落到最后的 "改版",而不是 "轻阅" + v, err := r.EvalAction(`(function () { + try { java.qread(); return '轻阅'; } catch (e) {} + try { java.deviceID(); return '苹果'; } catch (e) {} + return '改版'; + })()`, nil) + if err != nil { + t.Fatalf("环境探测执行失败: %v", err) + } + if got := anyToString(v); got != "改版" { + t.Fatalf("环境探测 = %q,期望 改版", got) + } +} + +// TestUpLoginDataAndReLoginView 表单回填与重画信号应被显式记录。 +func TestUpLoginDataAndReLoginView(t *testing.T) { + state := NewMemoryState() + r := NewJSRunner(JSConfig{State: state}) + + if _, err := r.EvalAction(`java.upLoginData({邮箱:'user@example.com', 昵称:'tester'})`, nil); err != nil { + t.Fatalf("upLoginData 失败: %v", err) + } + var m map[string]string + if err := json.Unmarshal([]byte(state.GetLoginInfo()), &m); err != nil { + t.Fatalf("登录信息不是 JSON: %q", state.GetLoginInfo()) + } + if m["邮箱"] != "user@example.com" || m["昵称"] != "tester" { + t.Fatalf("表单值未回填: %v", m) + } + if !state.UIRefreshRequested() { + t.Fatal("upLoginData 应请求重画表单") + } + // 标记是「取走即清」 + if state.UIRefreshRequested() { + t.Fatal("重画标记应被消费") + } + if _, err := r.EvalAction(`java.reLoginView()`, nil); err != nil { + t.Fatalf("reLoginView 失败: %v", err) + } + if !state.UIRefreshRequested() { + t.Fatal("reLoginView 应请求重画表单") + } + if _, err := r.EvalAction(`java.refreshExplore()`, nil); err != nil { + t.Fatalf("refreshExplore 失败: %v", err) + } + if !state.UIRefreshRequested() { + t.Fatal("refreshExplore 应请求重画表单") + } +} diff --git a/internal/service/reader/rule/datauri.go b/internal/service/reader/rule/datauri.go new file mode 100644 index 0000000..f22f7f7 --- /dev/null +++ b/internal/service/reader/rule/datauri.go @@ -0,0 +1,81 @@ +package rule + +import ( + "encoding/base64" + "encoding/hex" + "regexp" + "strings" +) + +// 本文件:data: 地址与「type 声明」——书源用它当参数信封。 +// +// 聚合类书源(光遇聚合等)会把上一阶段的结果打包进一个 data: 地址当 URL 用: +// +// searchUrl → `data:;base64,,{"type":"gysearch"}` +// bookUrl → `data:;base64,,{"type":"gydetail"}` +// tocUrl / chapterUrl 同理 +// +// legado 对这类地址的处理(AnalyzeUrl): +// 1. getByteArrayIfDataUri():地址以 data: 开头时本地 base64 解码取字节,不发网络请求; +// 2. getStrResponseAwait():只要选项里声明了 type(值本身不参与判断), +// 就直接返回 `HexUtil.encodeHexStr(字节)`——即原始字节的十六进制串。 +// +// 书源随后用 `java.hexDecodeToString(result)` 把 hex 还原成 JSON 取回参数, +// 再自行发出真正的请求。所以「type」不是内容类型,而是一个「请以 hex 返回 +// 原始字节」的开关;MeBox 早期把它当成不支持的能力直接拒绝了,导致这类书源 +// 在搜索第一步就报「书源 URL 声明了不支持的 type」。 + +// dataURIPayloadRe 对应 legado AppPattern.dataUriRegex:`^data:.*?;base64,(.*)`。 +// 只认带 `;base64,` 的数据地址(legado 也只处理这一种)。 +var dataURIPayloadRe = regexp.MustCompile(`(?s)^data:.*?;base64,(.*)$`) + +// DecodeDataURI 解析 `data:…;base64,…` 地址,返回解码后的原始字节。 +// ok 为 false 表示这不是一个可解析的 base64 数据地址。 +// 对应 legado AnalyzeUrl.getByteArrayIfDataUri()。 +func DecodeDataURI(raw string) ([]byte, bool) { + m := dataURIPayloadRe.FindStringSubmatch(strings.TrimSpace(raw)) + if m == nil { + return nil, false + } + // base64 允许换行与空白(书源拼接长载荷时常见),逐种变体尝试。 + cleaned := strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', ' ', '\t': + return -1 + } + return r + }, m[1]) + if cleaned == "" { + return []byte{}, true + } + for _, enc := range []*base64.Encoding{ + base64.StdEncoding, base64.RawStdEncoding, + base64.URLEncoding, base64.RawURLEncoding, + } { + if b, err := enc.DecodeString(cleaned); err == nil { + return b, true + } + } + return nil, false +} + +// IsDataURI 判断地址是否是 data: 地址。 +func IsDataURI(raw string) bool { + return strings.HasPrefix(strings.TrimSpace(raw), "data:") +} + +// EncodeRuleBody 按请求声明的响应形态把原始字节转成规则层看到的 body。 +// +// HexBody(选项里声明了 type)→ 原始字节的十六进制串 +// 其他 → 按 charset 解码的文本 +// +// 对应 legado getStrResponseAwait 里的两个分支。 +func EncodeRuleBody(req *Request, raw []byte, charset string) string { + if req != nil && req.HexBody { + return hex.EncodeToString(raw) + } + if body, err := DecodeBytes(raw, charset); err == nil { + return body + } + return string(raw) +} diff --git a/internal/service/reader/rule/datauri_test.go b/internal/service/reader/rule/datauri_test.go new file mode 100644 index 0000000..f03325c --- /dev/null +++ b/internal/service/reader/rule/datauri_test.go @@ -0,0 +1,108 @@ +package rule + +import ( + "encoding/base64" + "encoding/hex" + "strings" + "testing" +) + +// 本文件:data: 参数信封 + URL 选项 type 的回归测试。 +// +// 背景:聚合类书源把上一阶段的结果打包成 +// `data:;base64,,{"type":"gysearch"}` +// 当 URL 用。legado 对它的处理是:① 本地 base64 解码取字节;② 因为声明了 type, +// 把字节按 hex 返回。MeBox 早期把带 type 的 URL 直接判为「不支持的能力」, +// 导致这类书源第一步就报「书源 URL 声明了不支持的 type」。 + +func TestDecodeDataURI(t *testing.T) { + payload := `{"key":"宠魅","page":1}` + enc := base64.StdEncoding.EncodeToString([]byte(payload)) + + cases := map[string]string{ + "标准 base64": "data:;base64," + enc, + "带 MIME": "data:application/json;base64," + enc, + "无 padding": "data:;base64," + base64.RawStdEncoding.EncodeToString([]byte(payload)), + "URL-safe": "data:;base64," + base64.URLEncoding.EncodeToString([]byte(payload)), + "含空白/换行": "data:;base64," + wrapBase64(enc), + } + for name, raw := range cases { + got, ok := DecodeDataURI(raw) + if !ok { + t.Fatalf("%s: 应能解码", name) + } + if string(got) != payload { + t.Fatalf("%s: 解码结果 %q", name, string(got)) + } + } + + for _, raw := range []string{ + "https://example.com/a", + "data:text/plain,hello", // 非 base64 形式 + "", + } { + if _, ok := DecodeDataURI(raw); ok { + t.Fatalf("不应识别为 base64 数据地址: %q", raw) + } + } +} + +// TestParseAnalyzeUrlDataTypeDeclaration 带 type 的 data: 地址应被解析为 +// 「hex 返回」的请求,而不是不支持的能力。 +func TestParseAnalyzeUrlDataTypeDeclaration(t *testing.T) { + payload := base64.StdEncoding.EncodeToString([]byte(`{"key":"宠魅"}`)) + raw := "data:;base64," + payload + `,{"type":"gysearch"}` + + req, err := ParseAnalyzeUrlWithJS(raw, "", 0, "https://example.com", NewJSRunner(JSConfig{})) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if req.Unsupported != nil { + t.Fatalf("带 type 的地址不应被判为不支持: %v", req.Unsupported) + } + if !req.HexBody { + t.Fatal("声明了 type 时应按 hex 返回响应") + } + // 选项里的 JSON 不能被当成数据载荷的一部分 + if strings.Contains(req.URL, `"type"`) { + t.Fatalf("选项未与地址分离: %q", req.URL) + } + if _, ok := DecodeDataURI(req.URL); !ok { + t.Fatalf("URL 不是可解码的数据地址: %q", req.URL) + } +} + +// TestParseAnalyzeUrlWithoutType 没有 type 时不应进入 hex 模式。 +func TestParseAnalyzeUrlWithoutType(t *testing.T) { + req, err := ParseAnalyzeUrlWithJS("https://example.com/a?x=1", "", 0, "https://example.com", NewJSRunner(JSConfig{})) + if err != nil { + t.Fatal(err) + } + if req.HexBody { + t.Fatal("未声明 type 时不应按 hex 返回") + } + if req.URL != "https://example.com/a?x=1" { + t.Fatalf("URL = %q", req.URL) + } +} + +// TestEncodeRuleBody 声明 type 时返回原始字节的 hex,否则返回解码后的文本。 +func TestEncodeRuleBody(t *testing.T) { + raw := []byte(`{"key":"宠魅"}`) + + hexReq := &Request{HexBody: true} + got := EncodeRuleBody(hexReq, raw, "") + if got != hex.EncodeToString(raw) { + t.Fatalf("hex 模式 = %q", got) + } + // 书源会用它还原 + back, err := hex.DecodeString(got) + if err != nil || string(back) != string(raw) { + t.Fatalf("hex 往返失败: %v %q", err, string(back)) + } + + plainReq := &Request{} + if got := EncodeRuleBody(plainReq, raw, ""); got != string(raw) { + t.Fatalf("非 hex 模式 = %q", got) + } +} diff --git a/internal/service/reader/rule/errors.go b/internal/service/reader/rule/errors.go index dd6521d..07f6299 100644 --- a/internal/service/reader/rule/errors.go +++ b/internal/service/reader/rule/errors.go @@ -12,6 +12,4 @@ var ( ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持") // ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。 ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持") - // ErrTypeUnsupported 书源 URL 声明了 type(zip/file 等),暂不支持。 - ErrTypeUnsupported = errors.New("书源 URL 声明了不支持的 type") ) diff --git a/internal/service/reader/rule/goja.go b/internal/service/reader/rule/goja.go index 578714b..53345b1 100644 --- a/internal/service/reader/rule/goja.go +++ b/internal/service/reader/rule/goja.go @@ -1,6 +1,7 @@ package rule import ( + "context" "fmt" "strings" "sync" @@ -68,6 +69,80 @@ type JSConfig struct { // StateOnly 只构建会话状态与 jsLib 环境(登录交互用), // 不注入 book/result 等规则上下文。 StateOnly bool + // Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。 + // nil 时这两个函数抛出不支持错误。 + Browser BrowserHost + // Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。 + Ctx context.Context +} + +// interruptGuard 是 JS 执行超时的看门狗:到期后中断虚拟机。 +// +// 单独抽出来的原因是 java.startBrowserAwait 会阻塞等待用户在网页上操作 +// (可达数分钟),这段时间必须暂停计时,否则默认 10s 的超时会在用户还没 +// 点完 √ 时就把脚本打断。Pause/Resume 之间不计时,Resume 后重新起算完整 +// 的一段预算——语义即「每一段自动执行各有一次预算,等人不算」。 +type interruptGuard struct { + vm *goja.Runtime + timeout time.Duration + reason string + mu sync.Mutex + paused int + stopped bool + timer *time.Timer +} + +func newInterruptGuard(vm *goja.Runtime, timeout time.Duration, reason string) *interruptGuard { + g := &interruptGuard{vm: vm, timeout: timeout, reason: reason} + g.start() + return g +} + +// start 起一个新的超时计时(调用方需持锁或处于初始化阶段)。 +func (g *interruptGuard) start() { + g.timer = time.AfterFunc(g.timeout, func() { + g.mu.Lock() + skip := g.stopped || g.paused > 0 + g.mu.Unlock() + if !skip { + g.vm.Interrupt(g.reason) + } + }) +} + +// Pause 暂停计时(等待人工操作),返回恢复函数。 +func (g *interruptGuard) Pause() func() { + g.mu.Lock() + g.paused++ + if g.timer != nil { + g.timer.Stop() + g.timer = nil + } + g.mu.Unlock() + return g.Resume +} + +// Resume 恢复计时。 +func (g *interruptGuard) Resume() { + g.mu.Lock() + defer g.mu.Unlock() + if g.paused > 0 { + g.paused-- + } + if g.paused == 0 && !g.stopped && g.timer == nil { + g.start() + } +} + +// Stop 永久停止计时(本次 JS 执行结束)。 +func (g *interruptGuard) Stop() { + g.mu.Lock() + defer g.mu.Unlock() + g.stopped = true + if g.timer != nil { + g.timer.Stop() + g.timer = nil + } } // JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。 @@ -78,6 +153,28 @@ type JSRunner struct { state SourceState // jsLibErr 记录 jsLib 执行失败原因(登录接口需要如实回报)。 jsLibErr error + // guard 当前执行的超时看门狗;java.startBrowserAwait 阻塞期间置为 nil。 + guardMu sync.Mutex + guard *interruptGuard +} + +// setGuard 记录/清除当前执行的看门狗。 +func (r *JSRunner) setGuard(g *interruptGuard) { + r.guardMu.Lock() + r.guard = g + r.guardMu.Unlock() +} + +// pauseTimeout 暂停当前 JS 执行的超时计时,返回恢复函数。 +// 供 java.startBrowserAwait 在等待人工操作期间调用。 +func (r *JSRunner) pauseTimeout() func() { + r.guardMu.Lock() + g := r.guard + r.guardMu.Unlock() + if g == nil { + return func() {} + } + return g.Pause() } // NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。 @@ -119,8 +216,12 @@ func (r *JSRunner) loadJSLib() { r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err) return } - timer := time.AfterFunc(r.cfg.Timeout, func() { r.vm.Interrupt("jsLib 执行超时") }) - defer timer.Stop() + g := newInterruptGuard(r.vm, r.cfg.Timeout, "jsLib 执行超时") + r.setGuard(g) + defer func() { + g.Stop() + r.setGuard(nil) + }() if _, err := r.vm.RunProgram(prog); err != nil { r.jsLibErr = fmt.Errorf("jsLib 执行失败: %v", err) } @@ -160,8 +261,12 @@ func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("JS 编译失败: %w", err) } - timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") }) - defer timer.Stop() + g := newInterruptGuard(vm, r.cfg.Timeout, "JS 执行超时") + r.setGuard(g) + defer func() { + g.Stop() + r.setGuard(nil) + }() v, err := vm.RunProgram(prog) if err != nil { return nil, fmt.Errorf("JS 执行失败: %v", err) @@ -187,8 +292,12 @@ func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (s if err != nil { return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err) } - timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("loginCheckJs 执行超时") }) - defer timer.Stop() + g := newInterruptGuard(vm, r.cfg.Timeout, "loginCheckJs 执行超时") + r.setGuard(g) + defer func() { + g.Stop() + r.setGuard(nil) + }() v, err := vm.RunProgram(prog) if err != nil { return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err) @@ -232,8 +341,8 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a r.installJava(a) // 上下文绑定 if a != nil { - vm.Set("book", map[string]any{"name": a.bookName}) - vm.Set("chapter", map[string]any{"title": a.chapterTitle}) + vm.Set("book", newBookObject(vm, a)) + vm.Set("chapter", newChapterObject(vm, a)) vm.Set("title", a.chapterTitle) if a.content != nil { vm.Set("src", resultString(a.content)) @@ -247,11 +356,18 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a vm.Set("src", nil) } base := baseURL + // baseUrl 绑定优先用「解析器当前处理的页面地址」再退回书源地址。 + // + // 对应 legado:evalJS 里 bindings["baseUrl"] = analyzeRule.baseUrl, + // 而 baseUrl 由各阶段 setBaseUrl(bookUrl / tocUrl / chapterUrl) 设定。 + // 这一点很关键:聚合类书源会用 String(baseUrl).startsWith("data:") + // 判断「当前这一层是不是书源自搭的参数信封」,若把 baseUrl 固定成书源地址, + // 书源会走 else 分支直接把 hex 原文当结果返回,详情/目录随之全空。 + if base == "" && a != nil { + base = a.baseUrl + } if base == "" { base = r.cfg.BaseURL - if a != nil && base == "" { - base = a.baseUrl - } } vm.Set("baseUrl", base) vm.Set("result", toJSValue(vm, result)) @@ -267,12 +383,16 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a } vm.Set("nextChapterUrl", nil) - prog, err := compileCached(js) + prog, err := compileCached(scopedRuleJS(js)) if err != nil { return nil, fmt.Errorf("JS 编译失败: %w", err) } - timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") }) - defer timer.Stop() + g := newInterruptGuard(vm, r.cfg.Timeout, "JS 执行超时") + r.setGuard(g) + defer func() { + g.Stop() + r.setGuard(nil) + }() v, err := vm.RunProgram(prog) if err != nil { return nil, fmt.Errorf("JS 执行失败: %v", err) @@ -280,6 +400,31 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a return exportValue(v), nil } +// scopedRuleJS 把一段规则 JS 包进块作用域后编译。 +// +// 同一个 goja Runtime 会被一个书源的所有规则 JS 复用,而顶层 let/const 会留在 +// 全局词法环境里,于是「前一个脚本声明过的名字,后一个脚本再声明」会直接报 +// `SyntaxError: Identifier 'x' has already been declared`。 +// +// 典型触发(光遇聚合):搜索列表规则是 +// +// const { key, tab, sourcesKey, page, ... } = res; ...$.data +// +// 而单本书的 bookUrl 规则是 +// +// let book_id = ...; let tab = result.tab || '小说'; ... +// +// 两者在同一轮解析里先后执行,第二个必然编译失败;因为失败发生在「逐条取字段」 +// 阶段且被 continue 跳过,表现出来就是「搜索有结果但一条都读不出来」。 +// legado 用的 Rhino 对顶层 let 更宽松,所以同一书源在阅读 App 里是正常的。 +// +// 包一层块即可隔离词法声明,同时保留块最后表达式的值(JS 规范中块的完成值就是 +// 最后一条语句的值),也不改变 this(仍是全局对象,书源的 this.getVariable / +// this.BaseUrl 照常可用)。 +func scopedRuleJS(js string) string { + return "{\n" + js + "\n}" +} + // exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。 func exportValue(v goja.Value) any { if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { diff --git a/internal/service/reader/rule/js_scope_test.go b/internal/service/reader/rule/js_scope_test.go new file mode 100644 index 0000000..8043e4f --- /dev/null +++ b/internal/service/reader/rule/js_scope_test.go @@ -0,0 +1,98 @@ +package rule + +import ( + "strings" + "testing" +) + +// 本文件:规则 JS 作用域隔离的回归测试。 +// +// 同一个 goja Runtime 会被一个书源的所有规则 JS 复用,而顶层 let/const 会留在 +// 全局词法环境里。若不做隔离,「前一个脚本声明过的名字,后一个脚本再声明」就会 +// 报 `SyntaxError: Identifier 'x' has already been declared`。 +// +// 光遇聚合正好踩中:搜索列表规则是 `const { key, tab, ... } = res`, +// 单本书的 bookUrl 规则是 `let tab = result.tab || '小说'`。失败发生在「逐条取 +// 字段」阶段且被 continue 跳过,于是表现为「搜索有结果但一条都读不出来」。 +// legado 用的 Rhino 对顶层 let 更宽松,所以同一书源在阅读 App 里正常。 + +// TestRuleJSScopeIsIsolated 先后执行的规则 JS 不应因顶层 let/const 重名而失败。 +func TestRuleJSScopeIsIsolated(t *testing.T) { + r := NewJSRunner(JSConfig{}) + + // 第一个脚本:解构声明(搜索列表规则的写法) + v, err := r.Run(nil, `const { tab, key } = {tab:'A', key:'K'}; tab + key`, nil, "") + if err != nil { + t.Fatalf("第一个脚本失败: %v", err) + } + if got := anyToString(v); got != "AK" { + t.Fatalf("第一个脚本返回 %q", got) + } + + // 第二个脚本重名声明(bookUrl 规则的写法):不得报「已声明」 + for i := 0; i < 3; i++ { + v, err = r.Run(nil, `let tab = 'B'; let book_id = 'x' + tab; book_id`, nil, "") + if err != nil { + t.Fatalf("第 %d 次重名声明冲突: %v", i+2, err) + } + if got := anyToString(v); got != "xB" { + t.Fatalf("第 %d 次返回 %q", i+2, got) + } + } +} + +// TestRuleJSKeepsCompletionValue 包块不能吃掉「最后一条语句的值」—— +// 书源的 URL 规则几乎都靠这个完成值返回结果。 +func TestRuleJSKeepsCompletionValue(t *testing.T) { + r := NewJSRunner(JSConfig{}) + + v, err := r.Run(nil, "`data:;base64,AAAA,{\"type\":\"gysearch\"}`", nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); !strings.HasPrefix(got, "data:;base64,") { + t.Fatalf("模板字面量的完成值丢了: %q", got) + } + + // 末尾带行注释也不能把收尾的块注释掉 + v, err = r.Run(nil, "let a = 'ok'; a // 末尾注释", nil, "") + if err != nil { + t.Fatalf("带末尾注释的脚本失败: %v", err) + } + if got := anyToString(v); got != "ok" { + t.Fatalf("返回 %q", got) + } +} + +// TestRuleJSThisStaysGlobal 包块后 this 仍是全局对象, +// 书源的 this.getVariable / this.BaseUrl / this.request 才照常可用。 +func TestRuleJSThisStaysGlobal(t *testing.T) { + r := NewJSRunner(JSConfig{}) + v, err := r.Run(nil, `String(this === globalThis) + '|' + typeof java.ajax`, nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); got != "true|function" { + t.Fatalf("this 语义被破坏: %q", got) + } +} + +// TestRuleJSCanReadGlobalsFromJSLib 规则 JS 仍能读到 jsLib 定义的全局函数 +// 与顶层 lexical 绑定(hosts 这类)。 +func TestRuleJSCanReadGlobalsFromJSLib(t *testing.T) { + r := NewJSRunner(JSConfig{ + JSLib: `let hosts = ['https://v1.example.com']; +function BaseUrl(){ return hosts[0] } +function pick(k){ return k + '@' + BaseUrl() }`, + }) + if err := r.JSLibErr(); err != nil { + t.Fatalf("jsLib 失败: %v", err) + } + v, err := r.Run(nil, `{ let x = 'A'; pick(x) }`, nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); got != "A@https://v1.example.com" { + t.Fatalf("规则读不到 jsLib 的全局: %q", got) + } +} diff --git a/internal/service/reader/rule/jsobjects.go b/internal/service/reader/rule/jsobjects.go index 5b96978..edf2b6e 100644 --- a/internal/service/reader/rule/jsobjects.go +++ b/internal/service/reader/rule/jsobjects.go @@ -107,6 +107,85 @@ func newCookieObject(vm *goja.Runtime, state SourceState) *goja.Object { return o } +// ─── 书籍 / 章节对象 ─────────────────────────────────────────────────────── + +// newBookObject 构造规则 JS 里的 `book`(对应 legado 的 Book 实体)。 +// +// 书源会读它的元数据(name / author / coverUrl / durChapterIndex / order / type…)、 +// 给它赋值(book.type = …、book.imageStyle = …)、调用 setUseReplaceRule(), +// 以及用 getVariable / putVariable 读写书籍自定义变量。 +// +// 早期这里只绑了 {"name": ...},书源一碰 `book.setUseReplaceRule(false)` +// 就 TypeError,整段详情/目录规则 JS 直接失败(表现为「详情空白、目录 0 章」)。 +func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object { + o := vm.NewObject() + set := func(k string, v any) { + _ = o.Set(k, v) + } + for k, v := range a.bookMeta { + if k == "type" { + continue // type 用访问器,见下 + } + set(k, v) + } + // name 以 SetBookContext 的值为准(legado 里 book.name 就是这个) + set("name", a.bookName) + set("bookName", a.bookName) + + // book.type:书源会赋值来声明书籍类型(听书=1 / 漫画=2 …), + // legado 会把它写回 Book.type,服务层据此决定正文按文本/音频/图片返回。 + // 用访问器把写入记下来,否则赋值只活在本次 JS 里,读完仍是文本。 + _ = o.DefineAccessorProperty("type", + vm.ToValue(func(call goja.FunctionCall) goja.Value { return vm.ToValue(a.bookTypeValue()) }), + vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) && !goja.IsNull(call.Arguments[0]) { + a.SetBookType(int(call.Arguments[0].ToInteger())) + } + return goja.Undefined() + }), + goja.FLAG_FALSE, goja.FLAG_TRUE) + + // readConfig:书源读 book.readConfig.useReplaceRule,并可能回写 + rc := vm.NewObject() + _ = rc.Set("useReplaceRule", false) + set("readConfig", rc) + set("setUseReplaceRule", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + _ = rc.Set("useReplaceRule", call.Arguments[0].ToBoolean()) + } + return goja.Null() + }) + + // 书籍自定义变量(legado Book.variableMap)。 + // + // 注意「缺省返回空串」:legado 的 RuleDataInterface.getVariable 是 + // variableMap[key] ?: getBigVariable(key) ?: "" + // 返回 "" 而不是 null。书源会直接写 `String(book.getVariable('custom')) || ''`, + // 若这里返回 null,String(null) 得到字符串 "null"(真值),会被当成 + // tone_id 发给站点,站点直接返回空正文(表现为「正文 0 字」)。 + set("getVariable", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(a.bookCustom[stringArg(call, 0)]) + }) + set("putVariable", func(call goja.FunctionCall) goja.Value { + if a.bookCustom == nil { + a.bookCustom = map[string]string{} + } + a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "") + return goja.Null() + }) + return o +} + +// newChapterObject 构造规则 JS 里的 `chapter`(对应 legado 的 BookChapter)。 +func newChapterObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object { + o := vm.NewObject() + _ = o.Set("title", a.chapterTitle) + _ = o.Set("index", a.chapterIndex) + _ = o.Set("isVip", false) + _ = o.Set("isPay", false) + return o +} + // newSourceObject 构造 JS 的 `source` 对象。 // 对应 legado BaseSource 的变量与登录信息读写。 func newSourceObject(vm *goja.Runtime, state SourceState, props map[string]any) *goja.Object { diff --git a/internal/service/reader/rule/sample_source_test.go b/internal/service/reader/rule/sample_source_test.go index 22cc6bc..981ff8c 100644 --- a/internal/service/reader/rule/sample_source_test.go +++ b/internal/service/reader/rule/sample_source_test.go @@ -136,9 +136,9 @@ func TestLoginMissingCredentials(t *testing.T) { } } -// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错, -// 避免书源逻辑把空 body 当成校验成功。 -func TestStartBrowserAwaitRecordsURL(t *testing.T) { +// TestStartBrowserAwaitWithoutHost startBrowserAwait 在未注入宿主浏览器时应 +// 明确报错,并记录待打开地址,避免书源逻辑把空 body 当成校验成功。 +func TestStartBrowserAwaitWithoutHost(t *testing.T) { state := NewMemoryState() r := newSampleRunner(t, state) loginJS := loadTestdata(t, "sample_loginUrl.js") @@ -146,7 +146,7 @@ func TestStartBrowserAwaitRecordsURL(t *testing.T) { // 先造出登录态,让 user() 走到 startBrowserAwait state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn") _, err := r.EvalAction(loginJS+"\nuser()", nil) - if err == nil || !strings.Contains(err.Error(), "无浏览器") { + if err == nil || !strings.Contains(err.Error(), "浏览器") { t.Fatalf("应明确报不支持,实际: %v", err) } browsers := state.Browsers() diff --git a/internal/service/reader/rule/state.go b/internal/service/reader/rule/state.go index 345f589..69074da 100644 --- a/internal/service/reader/rule/state.go +++ b/internal/service/reader/rule/state.go @@ -1,6 +1,7 @@ package rule import ( + "encoding/json" "net" "net/url" "strings" @@ -43,6 +44,20 @@ type SourceState interface { OpenBrowser(url, title string) } +// UIState 是可选实现的登录界面信号接口。 +// +// java.reLoginView / java.refreshExplore / java.upLoginData 在 legado 中直接 +// 操作登录对话框的控件。服务端不能碰 DOM,因此把意图显式交给宿主处理, +// 而不是静默丢弃——静默丢弃会让书源以为表单已经按它的预期更新了。 +type UIState interface { + // RequestUIRefresh 请求宿主重新渲染登录表单 + // (对应 legado SourceLoginJsExtensions.reLoginView / refreshExplore)。 + RequestUIRefresh() + // ApplyLoginData 把书源给出的值合并进登录表单 + // (对应 legado SourceLoginJsExtensions.upLoginData)。 + ApplyLoginData(data map[string]string) +} + // CookieDomain 取 URL 的有效顶级域 +1(对应 legado NetworkUtils.getSubDomain)。 // 裸域名(无 scheme)按 http 处理;IP 原样返回;解析失败回退 host 本身。 func CookieDomain(rawURL string) string { @@ -114,6 +129,7 @@ type MemoryState struct { cookies map[string]map[string]string // domain → name → value toasts []string browsers []BrowserRequest + uiRefresh bool } // BrowserRequest 前端可代为打开的浏览器地址(java.startBrowser 收集)。 @@ -208,6 +224,42 @@ func (m *MemoryState) OpenBrowser(url, title string) { m.browsers = append(m.browsers, BrowserRequest{URL: url, Title: title}) } +// RequestUIRefresh 实现 UIState(进程内实现,供单测)。 +func (m *MemoryState) RequestUIRefresh() { + m.mu.Lock() + defer m.mu.Unlock() + m.uiRefresh = true +} + +// ApplyLoginData 实现 UIState:合并进 loginInfo。 +func (m *MemoryState) ApplyLoginData(data map[string]string) { + if len(data) == 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + cur := map[string]string{} + if m.loginInfo != "" { + _ = json.Unmarshal([]byte(m.loginInfo), &cur) + } + for k, v := range data { + cur[k] = v + } + if b, err := json.Marshal(cur); err == nil { + m.loginInfo = string(b) + } + m.uiRefresh = true +} + +// UIRefreshRequested 返回并清空「重画登录表单」标记。 +func (m *MemoryState) UIRefreshRequested() bool { + m.mu.Lock() + defer m.mu.Unlock() + out := m.uiRefresh + m.uiRefresh = false + return out +} + // Toasts 返回并清空已收集的宿主提示。 func (m *MemoryState) Toasts() []string { m.mu.Lock() diff --git a/internal/service/reader/rule/testdata/sample_loginUrl.js b/internal/service/reader/rule/testdata/sample_loginUrl.js index 7c759b1..cbba4a4 100644 --- a/internal/service/reader/rule/testdata/sample_loginUrl.js +++ b/internal/service/reader/rule/testdata/sample_loginUrl.js @@ -50,6 +50,28 @@ function user() { java.startBrowserAwait(BaseUrl() + '/user', '用户后台'); } +// 切换线路:与真实聚合源同构——把一段内嵌 HTML 交给宿主浏览器, +// 用户在页面里点选,宿主回传「操作后的页面源码」,书源再从 DOM 里 +// 解析出所选线路写进源变量。(光遇聚合的 getServerSettings 即此结构) +function switchLine() { + let hostsbk = (getVariable('云端配置') || {})['hosts'] || hosts; + let html = '' + + '' + BaseUrl() + '' + + 'true' + + ''; + let body = java.startBrowserAwait( + 'data:text/html;base64,' + java.base64Encode(html), '线路设置', false + ).body(); + let match = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/); + if (!match) { + java.longToast('解析线路失败'); + return; + } + setVariable('线路', match[1], false); + java.longToast('已切换到 ' + match[1]); + return match[1]; +} + // 查看信息 function checkStatus() { if (String(getToken()).length < 10) { diff --git a/internal/service/reader/rule/url.go b/internal/service/reader/rule/url.go index e0052ad..579da85 100644 --- a/internal/service/reader/rule/url.go +++ b/internal/service/reader/rule/url.go @@ -39,10 +39,14 @@ type Request struct { IsForm bool // Body 为已编码的 form 数据 IsJSON bool // 以 application/json 发送 Charset string + // HexBody 对应 URL 选项里的 type:声明了 type 时,响应按「原始字节的 hex」 + // 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。 + // 书源用它配合 data: 地址当参数信封,见 datauri.go。 + HexBody bool // BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。 BodyJsFn func(body string) string // Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力, - // 值为对应错误(webView/type;JS 在接入 runner 后已支持)。 + // 值为对应错误(webView;JS 在接入 runner 后已支持)。 Unsupported error } @@ -198,8 +202,10 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J } } req.Charset = option.Charset - if option.Type != "" && req.Unsupported == nil { - req.Unsupported = ErrTypeUnsupported + // 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按 + // 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。 + if option.Type != "" { + req.HexBody = true } if option.WebJs != "" && req.Unsupported == nil { req.Unsupported = ErrWebJSUnsupported @@ -232,6 +238,13 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J } // ── query / body 编码(对应 analyzeUrl 尾部) ── + // + // query 一律先做一次百分号编码规范化:legado 底层的 OkHttp 会把非 ASCII 与 + // 非法字符编码掉,而 Go 的 http 客户端会把 RawQuery 原样写进请求行 —— 原生 + // 中文、花括号、引号会直接上线,服务端多半回 400/空响应(书源侧表现为 + // request() 判定「线路报错」,把全部线路试一遍后返回空串)。 + // POST 同样要编码:query 并不会挪进 body。 + req.URL = normalizeQuery(req.URL, req.Charset) if req.Method == "POST" { req.URLNoQuery = req.URL body := req.Body @@ -241,23 +254,32 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J } else if isJSONStr(body) && req.Headers["Content-Type"] == "" { req.IsJSON = true } + } else if pos := strings.Index(req.URL, "?"); pos != -1 { + req.URLNoQuery = req.URL[:pos] } 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 - } + req.URLNoQuery = req.URL } return req, nil } +// normalizeQuery 把 URL 的 query 规范化成百分号编码形式。 +// 已经编码好的 query 原样保留(对应 NetworkUtils.encodedQuery 的短路),data: 地址不动。 +func normalizeQuery(rawURL, charset string) string { + if IsDataURI(rawURL) { + return rawURL + } + pos := strings.Index(rawURL, "?") + if pos < 0 { + return rawURL + } + base := rawURL[:pos] + query := encodeParams(rawURL[pos+1:], charset, true) + if query == "" { + return base + } + return base + "?" + query +} + var pagePatternRe = regexp.MustCompile(`<([^>]*)>`) func useWebView(raw json.RawMessage) bool { diff --git a/internal/service/reader/rule/url_binding_test.go b/internal/service/reader/rule/url_binding_test.go new file mode 100644 index 0000000..f3f436c --- /dev/null +++ b/internal/service/reader/rule/url_binding_test.go @@ -0,0 +1,149 @@ +package rule + +import ( + "strings" + "testing" +) + +// 本文件:URL query 规范化 与 规则 JS 上下文绑定的回归测试。 +// +// 背景(光遇聚合): +// - 书源把参数拼进 query 后交给 java.ajax,且 POST 也把参数放在 query 里; +// MeBox 早期只对 GET 重编码 query,POST 的原生中文/引号会直接写进请求行, +// 服务端回 400/空响应,书源判定「线路报错」并把全部线路试一遍。 +// - 书源用 String(baseUrl).startsWith("data:") 判断当前层是不是自搭的参数信封, +// 所以规则 JS 的 baseUrl 必须是「当前处理的页面地址」,不能固定成书源地址。 + +// TestNormalizeQueryEncodesNonASCII 非 ASCII 与非法字符必须被百分号编码。 +func TestNormalizeQueryEncodesNonASCII(t *testing.T) { + raw := `https://example.com/detail?id=abc&source=番茄&variable={"custom":""}` + got := normalizeQuery(raw, "") + if strings.Contains(got, "番茄") { + t.Fatalf("中文未被编码: %s", got) + } + if !strings.Contains(got, "source=%E7%95%AA%E8%8C%84") { + t.Fatalf("中文编码结果异常: %s", got) + } + if strings.Contains(got, `{"custom":""}`) { + t.Fatalf("引号未被编码: %s", got) + } + // 分隔符与等号必须保留,否则参数结构会散掉 + if !strings.Contains(got, "&") || !strings.Contains(got, "=") { + t.Fatalf("query 结构被破坏: %s", got) + } +} + +// TestNormalizeQueryKeepsEncoded 已编码好的 query 原样保留,不二次编码。 +func TestNormalizeQueryKeepsEncoded(t *testing.T) { + raw := "https://example.com/s?q=%E5%AE%A0%E9%AD%85&page=1" + if got := normalizeQuery(raw, ""); got != raw { + t.Fatalf("已编码 query 被改动:\n got=%s\nwant=%s", got, raw) + } +} + +// TestNormalizeQueryLeavesDataURI data: 地址的载荷不是 query,不能动。 +func TestNormalizeQueryLeavesDataURI(t *testing.T) { + raw := `data:;base64,eyJhIjoxfQ==` + if got := normalizeQuery(raw, ""); got != raw { + t.Fatalf("data 地址被改动: %s", got) + } +} + +// TestParseAnalyzeUrlEncodesPOSTQuery POST 的 query 也要编码。 +// 回归:之前只有 GET 分支做重编码,POST 的原生中文直接上线。 +func TestParseAnalyzeUrlEncodesPOSTQuery(t *testing.T) { + raw := `https://example.com/detail?source=番茄,{"method":"POST","headers":{"Content-Type":"application/json"},"body":"{\"html\":\"\"}"}` + req, err := ParseAnalyzeUrlWithJS(raw, "", 0, "https://example.com", NewJSRunner(JSConfig{})) + if err != nil { + t.Fatal(err) + } + if req.Method != "POST" { + t.Fatalf("method = %q", req.Method) + } + if strings.Contains(req.URL, "番茄") { + t.Fatalf("POST query 未编码: %s", req.URL) + } + if !strings.Contains(req.URL, "source=%E7%95%AA%E8%8C%84") { + t.Fatalf("POST query 编码异常: %s", req.URL) + } + if req.Body != `{"html":""}` { + t.Fatalf("body 被改动: %q", req.Body) + } +} + +// TestRuleJSBaseUrlPrefersPageURL 规则 JS 的 baseUrl 应是「当前页面地址」。 +// +// 书源用 String(baseUrl).startsWith("data:") 判断当前层是不是自搭的参数信封; +// 若把 baseUrl 固定成书源地址,书源会走 else 分支把 hex 原文当结果返回。 +func TestRuleJSBaseUrlPrefersPageURL(t *testing.T) { + const pageURL = `data:;base64,eyJhIjoxfQ==` + r := NewJSRunner(JSConfig{BaseURL: "https://source.example.com"}) + ar := NewAnalyzeRule() + ar.SetContent("body", pageURL) + + v, err := r.Run(ar, `baseUrl`, nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); got != pageURL { + t.Fatalf("baseUrl = %q,期望页面地址 %q", got, pageURL) + } + if !strings.HasPrefix(anyToString(v), "data:") { + t.Fatal("书源的 data: 分支判断会失效") + } +} + +// TestRuleJSBaseUrlFallsBackToSource 页面地址为空时回退书源地址。 +func TestRuleJSBaseUrlFallsBackToSource(t *testing.T) { + r := NewJSRunner(JSConfig{BaseURL: "https://source.example.com"}) + v, err := r.Run(NewAnalyzeRule(), `baseUrl`, nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); got != "https://source.example.com" { + t.Fatalf("baseUrl = %q", got) + } +} + +// TestRuleJSBookObject 规则 JS 的 book 对象要具备书源依赖的成员。 +// +// 回归:早期只绑了 {"name": ...},书源一碰 book.setUseReplaceRule(false) +// 就 TypeError,整段详情/目录规则 JS 直接失败。 +func TestRuleJSBookObject(t *testing.T) { + state := NewMemoryState() + r := NewJSRunner(JSConfig{State: state}) + ar := NewAnalyzeRule() + ar.SetBookContext("宠魅", nil) + ar.SetChapterContext("第1集", nil) + ar.SetChapterIndex(0) + + js := `(function(){ + book.setUseReplaceRule(false); // 早期会 TypeError + var v = String(book.getVariable('custom')); // 缺省必须是 ""(不是 "null") + book.type = 32; // 听书源这样声明类型 + book.imageStyle = 'TEXT'; + return book.name + '|' + v + '|' + book.type + '|' + chapter.title; + })()` + v, err := r.Run(ar, js, nil, "") + if err != nil { + t.Fatalf("book 对象成员缺失: %v", err) + } + if got := anyToString(v); got != "宠魅||32|第1集" { + t.Fatalf("book 行为异常: %q", got) + } + // 书源声明的类型要被服务层读回 + if bt, ok := ar.BookTypeOverride(); !ok || bt != 32 { + t.Fatalf("BookTypeOverride = %v/%v,期望 32", bt, ok) + } + // putVariable 写回后 getVariable 能读到 + if _, err := r.Run(ar, `book.putVariable('custom','v1')`, nil, ""); err != nil { + t.Fatal(err) + } + v, err = r.Run(ar, `String(book.getVariable('custom'))`, nil, "") + if err != nil { + t.Fatal(err) + } + if got := anyToString(v); got != "v1" { + t.Fatalf("putVariable 后 getVariable = %q", got) + } +} diff --git a/internal/service/reader/search_query_test.go b/internal/service/reader/search_query_test.go new file mode 100644 index 0000000..f835902 --- /dev/null +++ b/internal/service/reader/search_query_test.go @@ -0,0 +1,139 @@ +package reader + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 本文件:GET 请求的 query 必须被保留并重编码。 +// +// 回归:executeWithState 曾拿 req.URLNoQuery 当请求目标。对 GET 来说那是**去掉 +// query** 的裸路径,于是所有「参数写在 query 里」的请求(搜索关键词、分页、 +// 平台筛选等)到了站点只剩路径。光遇聚合的 /search 因此收到空参数,返回 +// `{"code":-1,"msg":"参数不能为空"}`,表现为「搜索无结果」。 +// legado 的 GET 走 `get(urlNoQuery, encodedQuery)`,两者拼起来才是完整地址。 + +// queryEchoSource 构造一个把查询参数回显成 JSON 的书源。 +func queryEchoSource(serverURL string) string { + src := map[string]any{ + "bookSourceUrl": serverURL, + "bookSourceName": "query 回显源", + "searchUrl": serverURL + "/search?q={{key}}&page={{page}}", + "ruleSearch": map[string]any{ + "bookList": "$.data", + "name": "$.book_name", + "author": "$.author", + "bookUrl": "$.book_url", + }, + } + out, _ := json.Marshal(src) + return string(out) +} + +func TestSearchSendsQueryString(t *testing.T) { + var gotURI, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURI = r.URL.RequestURI() + gotQuery = r.URL.Query().Get("q") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":[{"book_name":"宠魅","author":"安橙花","book_url":"/book/1"}]}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + _ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) + + books, skipped, err := svc.Search(t.Context(), "宠魅") + if err != nil { + t.Fatalf("搜索失败: %v", err) + } + if len(skipped) > 0 { + t.Fatalf("书源被跳过: %+v", skipped) + } + if len(books) != 1 { + t.Fatalf("应搜到 1 条,实际 %d 条", len(books)) + } + if books[0].Name != "宠魅" || books[0].Author != "安橙花" { + t.Fatalf("书目解析异常: %+v", books[0]) + } + // 服务端必须真的收到 query —— 这正是之前丢掉的部分 + if gotQuery != "宠魅" { + t.Fatalf("服务端收到的 q = %q,请求 URI = %q", gotQuery, gotURI) + } + if !strings.Contains(gotURI, "page=1") { + t.Fatalf("分页参数丢失: %q", gotURI) + } + // 中文必须被百分号编码后才上线(原始 UTF-8 字节进请求行是非法的) + if strings.Contains(gotURI, "宠") { + t.Fatalf("query 未做百分号编码: %q", gotURI) + } +} + +// TestSearchResponseUsesEmptyArrays 搜索返回的列表字段必须是空切片而不是 nil。 +// +// 回归:skipped 为 nil 切片时会被编码成 JSON null,前端 `skipped.length` +// 直接抛 TypeError,整页被错误边界接管(表现为「页面加载失败」)。 +// handler 把这两个值原样交给 c.JSON,所以按同样的方式编码即可验证契约。 +func TestSearchResponseUsesEmptyArrays(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + _ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) + + books, skipped, err := svc.Search(t.Context(), "宠魅") + if err != nil { + t.Fatalf("搜索失败: %v", err) + } + payload, err := json.Marshal(map[string]any{"books": books, "skipped": skipped}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), `"skipped":null`) { + t.Fatalf("skipped 被编码成 null(前端会按数组用): %s", payload) + } + if strings.Contains(string(payload), `"books":null`) { + t.Fatalf("books 被编码成 null: %s", payload) + } + if !strings.Contains(string(payload), `"skipped":[]`) { + t.Fatalf("skipped 应为空数组: %s", payload) + } +} + +// TestSearchPreservesExistingPercentEncoding 已经编码好的 query 不应被二次编码。 +func TestSearchPreservesExistingPercentEncoding(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query().Get("q") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + src := map[string]any{ + "bookSourceUrl": srv.URL, + "bookSourceName": "已编码 query 源", + "searchUrl": srv.URL + "/search?q=%E5%AE%A0%E9%AD%85&page={{page}}", + "ruleSearch": map[string]any{ + "bookList": "$.data", + "name": "$.book_name", + "bookUrl": "$.book_url", + }, + } + out, _ := json.Marshal(src) + _ = prepareLoginSource(t, svc, string(out)) + + if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil { + t.Fatalf("搜索失败: %v", err) + } + if gotQuery != "宠魅" { + t.Fatalf("已编码的 query 被破坏: %q", gotQuery) + } +} diff --git a/internal/service/reader/state.go b/internal/service/reader/state.go index 99c6e5b..395f6ec 100644 --- a/internal/service/reader/state.go +++ b/internal/service/reader/state.go @@ -28,7 +28,10 @@ type sourceState struct { toasts []string browsers []rule.BrowserRequest - dirty bool + // uiRefresh 书源通过 java.reLoginView / refreshExplore / upLoginData + // 请求重新渲染登录表单(对应 legado 直接操作对话框控件)。 + uiRefresh bool + dirty bool } // newSourceState 载入指定书源的会话状态(含解密)。 @@ -157,6 +160,34 @@ func (st *sourceState) RemoveCookie(rawURL string) { func (st *sourceState) Toast(msg string) { st.toasts = append(st.toasts, msg) } +// RequestUIRefresh 实现 rule.UIState:书源要求重画登录表单。 +func (st *sourceState) RequestUIRefresh() { st.uiRefresh = true } + +// ApplyLoginData 实现 rule.UIState:把书源给出的值合并进已保存的登录信息。 +func (st *sourceState) ApplyLoginData(data map[string]string) { + if len(data) == 0 { + return + } + cur := map[string]string{} + if st.loginInfo != "" { + _ = json.Unmarshal([]byte(st.loginInfo), &cur) + } + for k, v := range data { + cur[k] = v + } + if b, err := json.Marshal(cur); err == nil { + st.SetLoginInfo(string(b)) + } + st.uiRefresh = true +} + +// UIRefreshRequested 返回并清空「重画登录表单」标记。 +func (st *sourceState) UIRefreshRequested() bool { + out := st.uiRefresh + st.uiRefresh = false + return out +} + func (st *sourceState) OpenBrowser(url, title string) { st.browsers = append(st.browsers, rule.BrowserRequest{URL: url, Title: title}) } diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index a727a84..cf7cf51 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -65,6 +65,13 @@ export interface ReaderBook { dur_chapter_title: string dur_chapter_time: number order: number + /** 本地导入书籍(TXT / EPUB / 有声书目录),正文在服务端,不走书源。 */ + is_local: boolean + /** 原地引用服务器上已有的文件/目录:移出书架只解除引用,不删除源文件。 */ + local_external: boolean + /** 听书跳过片头/片尾秒数(0 表示不跳过,对应 legado Book.openCredits/closeCredits)。 */ + open_credits: number + close_credits: number } export interface ReaderBookInfo { @@ -100,6 +107,8 @@ export interface ReaderChapterContent { tracks?: string[] images?: string[] image_style?: string + /** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */ + transcoding?: boolean } export interface ReaderReplaceRule { @@ -148,11 +157,26 @@ export interface ReaderBrowserRequest { title: string } +/** 书源 JS 交给宿主浏览器承载的一个页面(java.startBrowser / startBrowserAwait)。 */ +export interface ReaderBrowserPage { + id: string + title: string + /** wait = 需要回传 DOM(点 √);open = 仅展示。 */ + mode: 'wait' | 'open' + /** iframe 承载地址(同源,带签名)。 */ + page_url: string + refetch: boolean + source_id: string + target_url?: string +} + export interface ReaderLoginResult { ok: boolean error?: string toasts?: string[] browsers?: ReaderBrowserRequest[] + /** 书源要求重新渲染登录表单(java.reLoginView / refreshExplore / upLoginData)。 */ + ui_refresh?: boolean values: Record cookies: Record logged_in: boolean @@ -160,6 +184,10 @@ export interface ReaderLoginResult { const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const +// 登录动作可能阻塞等待用户在页面里操作(java.startBrowserAwait), +// 服务端上限 10 分钟,这里留出余量,避免 axios 先超时把请求掐掉。 +const browserWaitOpts = { timeout: 11 * 60_000 } as const + export const readerAPI = { // ── 书源 ── listSources: () => api.get<{ sources: ReaderSource[] }>('/reader/sources').then((r) => r.data.sources), @@ -175,7 +203,30 @@ export const readerAPI = { sourceLogin: (id: string) => api.get(`/reader/sources/${id}/login`).then((r) => r.data), // 执行登录动作:action 为空表示执行 loginUrl 的 login()(确认登录) runSourceLogin: (id: string, body: { action?: string; fields?: Record }) => - api.post(`/reader/sources/${id}/login`, body, longOpts).then((r) => r.data), + api.post(`/reader/sources/${id}/login`, body, browserWaitOpts).then((r) => r.data), + // 书源 JS 的宿主浏览器:轮询待用户完成的页面(startBrowserAwait 会阻塞在服务端) + browserPending: (sourceId: string) => + api + .get<{ pages: ReaderBrowserPage[] }>('/reader/browser/pending', { params: { source_id: sourceId } }) + .then((r) => r.data.pages ?? []), + // 回传用户操作后的 DOM(或取消),解除服务端阻塞 + submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) => + api.post('/reader/browser/result', body).then((r) => r.data), + // 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie) + browserXHR: (body: { + id: string + url: string + method?: string + headers?: Record + body?: string + }) => + api + .post<{ status: number; content_type: string; body: string; base64: boolean }>( + '/reader/browser/xhr', + body, + longOpts, + ) + .then((r) => r.data), // 仅保存表单值,不触发登录 saveSourceLoginInfo: (id: string, fields: Record) => api.put(`/reader/sources/${id}/login-info`, { fields }), @@ -185,7 +236,11 @@ export const readerAPI = { // ── 搜索 / 详情 / 目录 / 正文 ── search: (key: string) => api - .post<{ books: ReaderSearchBook[]; skipped: ReaderSearchSkipped[] }>('/reader/search', { key }, longOpts) + .post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>( + '/reader/search', + { key }, + longOpts, + ) .then((r) => r.data), bookInfo: (params: { source_id?: string; source_url?: string; book_url: string }) => api.get('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data), @@ -202,9 +257,37 @@ export const readerAPI = { listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books), addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) => api.post('/reader/books', body).then((r) => r.data), + /** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */ + uploadLocalBook: (file: File, onProgress?: (percent: number) => void) => + api + .post('/reader/local/books', (() => { + const form = new FormData() + form.append('file', file) + return form + })(), { + timeout: 0, + onUploadProgress: (e) => { + if (!onProgress || !e.total) return + onProgress(Math.round((e.loaded / e.total) * 100)) + }, + }) + .then((r) => r.data), + /** 从服务器已有文件导入书籍(TXT / EPUB),原地引用不复制。仅管理员。 */ + importLocalBookFromPath: (path: string) => + api + .post('/reader/local/books/from-path', { path }, { timeout: LONG_REQUEST_TIMEOUT }) + .then((r) => r.data), + /** 把服务器上的一本目录导入为有声书(音频文件 + .strm 播放指针)。仅管理员。 */ + importLocalAudioDir: (path: string) => + api + .post('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT }) + .then((r) => r.data), removeBook: (id: string) => api.delete(`/reader/books/${id}`), saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) => api.put(`/reader/books/${id}/progress`, body), + // 听书跳过片头/片尾(秒,0 不跳过) + saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) => + api.put(`/reader/books/${id}/audio-config`, body), listChapters: (id: string) => api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters), saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }), diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index ae575ab..aa5e84c 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { prefetchCommonRouteChunks } from '../appRoutes' import { useAuthStore } from '../stores/auth' import { usePlayProfileStore } from '../stores/playProfile' +import { useReaderSettingsStore } from '../stores/readerSettings' import { LayoutHeader, LayoutSidebars, @@ -48,6 +49,7 @@ export function Layout() { const user = useAuthStore((s) => s.user) const logout = useAuthStore((s) => s.logout) + const homeMode = useReaderSettingsStore((s) => s.homeMode) const activeProfileId = usePlayProfileStore((s) => s.activeProfileId) const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile) const theme = useThemeMode() @@ -61,7 +63,9 @@ export function Layout() { const showSidebar = !isMediaView(location.pathname, location.search) const hideSearch = location.pathname.startsWith('/settings') const isPlayPage = isPlayerRoute(location.pathname) - const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname) + // 阅读模式:首页切到阅读后,影视那一套壳(媒体搜索、账号菜单、底部导航)都不该出现 + const readingMode = homeMode === 'reading' && location.pathname === '/' + const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname) && !readingMode return ( @@ -84,6 +88,8 @@ export function Layout() { onLogout={closeProfileAndLogout} showSidebar={showSidebar} hideSearch={hideSearch} + readingMode={readingMode} + showReaderToggle={location.pathname === '/'} pathname={location.pathname} /> )} diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index 1470db3..8e2bfc6 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' +import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' import { ARTWORK, imageURL } from '../api/client' import { mediaAPI } from '../api/library' +import { readerAPI, type ReaderBook } from '../api/reader' import type { Media, PlayProfile, User } from '../types' import { favouriteMediaLink } from '../utils/mediaNavigation' import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' +import { LayoutReaderModeToggle } from './LayoutReaderModeToggle' import type { useLayoutProfiles } from './useLayoutProfiles' import type { ThemeMode, useThemeMode } from './useThemeMode' @@ -31,6 +33,10 @@ type LayoutHeaderProps = { onLogout: () => void showSidebar?: boolean hideSearch?: boolean + /** 阅读模式:顶部搜索换成书搜索,并保留账号菜单。 */ + readingMode?: boolean + /** 是否显示顶栏的「影视 / 阅读」图标切换(只在首页)。 */ + showReaderToggle?: boolean pathname?: string } @@ -44,6 +50,8 @@ export function LayoutHeader({ onLogout, showSidebar, hideSearch, + readingMode, + showReaderToggle, pathname = '', }: LayoutHeaderProps) { const navigate = useNavigate() @@ -85,9 +93,11 @@ export function LayoutHeader({ {/* Middle: Search Box */} - {!hideSearch && } + {readingMode ? : !hideSearch && } + {showReaderToggle && } + {/* Right: Actions (Theme Toggle & User Menu) */} (null) + const [loading, setLoading] = useState(false) + const containerRef = useRef(null) + const inputRef = useRef(null) + + const openDropdown = () => { + setIsOpen(true) + if (books !== null || loading) return + setLoading(true) + readerAPI + .listBooks() + .then(setBooks) + .catch(() => setBooks([])) + .finally(() => setLoading(false)) + } + + const close = () => setIsOpen(false) + + useEffect(() => { + if (!isOpen) return + const onPointerDown = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) close() + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [isOpen]) + + const keyword = query.trim().toLowerCase() + const matched = (books ?? []) + .filter( + (b) => + !keyword || + b.name.toLowerCase().includes(keyword) || + (b.author ?? '').toLowerCase().includes(keyword), + ) + .slice(0, 8) + + const openBook = (book: ReaderBook) => { + close() + setQuery('') + navigate(`/reader/view/${book.id}`) + } + + const searchSources = () => { + const key = query.trim() + if (!key) return + close() + navigate(`/reader/search?key=${encodeURIComponent(key)}`) + } + + return ( + + + + { + setQuery(e.target.value) + setIsOpen(true) + }} + onFocus={openDropdown} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (matched.length > 0) openBook(matched[0]) + else searchSources() + } else if (e.key === 'Escape') { + close() + } + }} + placeholder="搜索书籍…" + className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]" + /> + {loading ? ( + + ) : query ? ( + { + setQuery('') + inputRef.current?.focus() + setIsOpen(true) + }} + className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg" + aria-label="清空" + > + + + ) : null} + + + {isOpen && ( + + {books === null || loading ? ( + + + 正在加载书架… + + ) : matched.length === 0 ? ( + + {query.trim() ? `书架里没有与 “${query}” 相关的书` : '书架还是空的,先导入书源或本地书籍'} + + ) : ( + + + {query.trim() ? '书架匹配' : '书架'} + + {matched.map((book) => ( + { + if (e.button !== 0) return + e.preventDefault() + openBook(book) + }} + onClick={() => openBook(book)} + className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group" + > + + {book.cover_url ? ( + + ) : ( + + + + )} + + + + + {book.name} + + {book.is_local && ( + + 本地 + + )} + + + {book.author || '佚名'} + {book.dur_chapter_title && 读到 {book.dur_chapter_title}} + + + + ))} + + )} + + { + if (e.button !== 0) return + e.preventDefault() + searchSources() + }} + onClick={searchSources} + disabled={!query.trim()} + className="mt-1 flex w-full items-center gap-2 rounded-xl border border-dashed border-[var(--app-border)] px-3 py-2 text-left text-xs font-bold text-brand-600 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-50" + > + + {query.trim() ? `在书源中搜索「${query.trim()}」` : '输入关键词后可在书源中搜索'} + + + )} + + ) +} + const SEARCH_PAGE_SIZE = 8 function LayoutHeaderSearch() { diff --git a/web/src/components/LayoutReaderModeToggle.tsx b/web/src/components/LayoutReaderModeToggle.tsx new file mode 100644 index 0000000..83ea406 --- /dev/null +++ b/web/src/components/LayoutReaderModeToggle.tsx @@ -0,0 +1,37 @@ +import { BookOpen, Clapperboard } from 'lucide-react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { useReaderSettingsStore } from '../stores/readerSettings' + +// 顶栏的「影视 / 阅读」切换:只显示当前模式的图标(影视 / 阅读),点一下切到另一个模块。 +// 模式只作用于首页(`/`),所以不在首页时顺带跳回首页,避免点了没反应。 +export function LayoutReaderModeToggle() { + const navigate = useNavigate() + const location = useLocation() + const homeMode = useReaderSettingsStore((s) => s.homeMode) + const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode) + + const reading = homeMode === 'reading' + const label = reading ? '当前是阅读模式,点击切换到影视' : '当前是影视模式,点击切换到阅读' + + const toggle = () => { + setHomeMode(reading ? 'media' : 'reading') + if (location.pathname !== '/') navigate('/') + } + + return ( + + {reading ? : } + + ) +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index e54e02b..85c2435 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -24,7 +24,6 @@ import { HomeLoadingState, } from './HomePageSections' import { ReaderHomeContent } from './reader/ReaderHomeContent' -import { ReaderModeSwitch } from './reader/ReaderModeSwitch' import { useReaderSettingsStore } from '../stores/readerSettings' const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) @@ -323,7 +322,7 @@ export function HomePage() { if (homeMode === 'reading') { return ( - + ) } @@ -340,7 +339,6 @@ export function HomePage() { return ( - {/* 1. 顶部海报轮播区 */} {carouselItems.length > 0 && ( diff --git a/web/src/pages/reader/BrowserPanel.tsx b/web/src/pages/reader/BrowserPanel.tsx new file mode 100644 index 0000000..80727da --- /dev/null +++ b/web/src/pages/reader/BrowserPanel.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Check, ExternalLink, Loader2, RefreshCw, X } from 'lucide-react' + +import { readerAPI, type ReaderBrowserPage } from '../../api/reader' + +// 书源页面的承载面板(对应 legado 的 BottomWebViewDialog)。 +// +// 书源通过 java.startBrowser / java.startBrowserAwait 把页面交给宿主: +// - startBrowser / showBrowser:只展示,用户看完关掉即可; +// - startBrowserAwait:服务端会一直阻塞,直到这里的「√」把用户操作后的 +// DOM 回传过去,书源再从 DOM 里解析出结果(光遇聚合的「切换线路」 +// 就是从回传页面里抓 #serverValue 写进源变量)。 +// +// iframe 刻意不带 allow-same-origin:页面是第三方 HTML,给它同源权限就能读写 +// 本应用的 localStorage(JWT)。因此改由服务端注入的脚本用 postMessage 回传 +// DOM,父窗口只负责发一个「请把当前 DOM 给我」的消息。 + +interface Props { + page: ReaderBrowserPage + onClose: () => void +} + +/** 请求 iframe 回传 DOM 的消息标识(与 browser_panel.go 注入的脚本一致)。 */ +const DOM_REQUEST = '__mebox_dom__' + +/** iframe 内页面的 fetch/XHR 代理消息(同浏览器 bridge 脚本)。 */ +interface ProxyRequest { + __mebox_proxy__?: boolean + reqId?: string + url?: string + method?: string + headers?: Record + body?: string +} + +export default function BrowserPanel({ page, onClose }: Props) { + const iframeRef = useRef(null) + const awaitingRef = useRef(false) + const timerRef = useRef(undefined) + const [busy, setBusy] = useState(false) + const [awaiting, setAwaiting] = useState(false) + const [error, setError] = useState('') + const [loaded, setLoaded] = useState(false) + + // 提交回传结果:body 为用户操作后的页面 DOM(服务端作为 StrResponse.body()) + const submit = useCallback( + async (html: string | null) => { + setBusy(true) + setError('') + try { + await readerAPI.submitBrowserResult({ + id: page.id, + body: html ?? '', + url: page.target_url ?? '', + cancelled: html === null, + }) + onClose() + } catch (e) { + setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '提交失败,请重试') + } finally { + setBusy(false) + } + }, + [page.id, page.target_url, onClose], + ) + + // 接收 iframe 的消息:① 回传 DOM;② 代发页面自己的接口请求 + useEffect(() => { + const onMessage = (ev: MessageEvent) => { + const data = ev.data as ProxyRequest & { __mebox_dom__?: boolean; html?: string } | null + if (!data) return + + if (data.__mebox_dom__ === true) { + if (!awaitingRef.current) return + awaitingRef.current = false + setAwaiting(false) + if (timerRef.current) window.clearTimeout(timerRef.current) + void submit(data.html ?? '') + return + } + + if (data.__mebox_proxy__ === true && data.reqId) { + const win = iframeRef.current?.contentWindow + const reply = (payload: Record) => { + win?.postMessage({ __mebox_proxy_res__: true, reqId: data.reqId, ...payload }, '*') + } + void readerAPI + .browserXHR({ + id: page.id, + url: data.url ?? '', + method: data.method ?? 'GET', + headers: data.headers ?? {}, + body: data.body ?? '', + }) + .then((res) => reply({ ...res })) + .catch((e) => { + // 让页面自己走失败分支,而不是一直挂着 + reply({ + status: 502, + contentType: 'text/plain', + body: String((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '代理请求失败'), + base64: false, + }) + }) + } + } + window.addEventListener('message', onMessage) + return () => { + window.removeEventListener('message', onMessage) + if (timerRef.current) window.clearTimeout(timerRef.current) + } + }, [submit, page.id]) + + const confirm = () => { + const win = iframeRef.current?.contentWindow + if (!win) { + setError('页面尚未加载完成') + return + } + setError('') + awaitingRef.current = true + setAwaiting(true) + try { + win.postMessage(DOM_REQUEST, '*') + } catch { + awaitingRef.current = false + setAwaiting(false) + setError('无法读取页面内容') + return + } + // 兜底:3 秒没回传就提示用户页面可能还没准备好 + timerRef.current = window.setTimeout(() => { + if (!awaitingRef.current) return + awaitingRef.current = false + setAwaiting(false) + setError('读取页面内容超时,请确认页面已加载完成后重试') + }, 3000) + } + + const externalURL = page.target_url && !page.target_url.startsWith('data:') ? page.target_url : '' + const needConfirm = page.mode === 'wait' + + return ( + e.stopPropagation()} + > + + {/* 顶栏:标题 + 外部打开 + 关闭 */} + + + + {page.title || '书源页面'} + + + {page.mode === 'wait' ? '操作完成后点右下角「完成」把结果回传给书源' : '确认完毕后关闭即可'} + {page.target_url ? ` · ${page.target_url}` : ''} + + + {!loaded && } + { + setLoaded(false) + if (iframeRef.current) iframeRef.current.src = page.page_url + }} + className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]" + > + + + {externalURL && ( + window.open(externalURL, '_blank', 'noopener,noreferrer')} + className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]" + > + + + )} + + + {error && ( + {error} + )} + + {/* 页面本体:sandbox 不含 allow-same-origin,避免第三方页面拿到本应用权限 */} + + setLoaded(true)} + sandbox="allow-scripts allow-forms allow-popups allow-modals" + className="h-full w-full border-0" + /> + + + {/* 底部动作 */} + + void submit(null)} + className="btn-outline text-xs text-red-500 disabled:opacity-50" + > + 取消 + + {needConfirm && ( + + {awaiting ? ( + + ) : ( + + )} + 完成(回传页面) + + )} + + + + ) +} diff --git a/web/src/pages/reader/ReaderAudioPanel.tsx b/web/src/pages/reader/ReaderAudioPanel.tsx index 49b556d..4446732 100644 --- a/web/src/pages/reader/ReaderAudioPanel.tsx +++ b/web/src/pages/reader/ReaderAudioPanel.tsx @@ -1,56 +1,147 @@ -import { useEffect, useRef, useState } from 'react' -import { ChevronLeft, ChevronRight, Loader2, Pause, Play } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { + ChevronLeft, + ChevronRight, + Gauge, + ListMusic, + Loader2, + Pause, + Play, + Scissors, + Timer, + X, +} from 'lucide-react' +import { Virtuoso } from 'react-virtuoso' -// 音频播放面板(仿 legado ReadAloudDialog transport 行): -// hls.js 播 m3u8, 播直链;上一章/播放暂停/下一章 + 进度条 + 倍速。 +import type { ReaderChapter } from '../../api/reader' +import { + AUDIO_CREDITS_MAX, + AUDIO_SPEEDS, + AUDIO_TIMERS, + useReaderSettingsStore, +} from '../../stores/readerSettings' + +// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行): +// hls.js 播 m3u8, 播直链。 +// transport:上一章 | -15s | 播放暂停 | +15s | 下一章; +// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。 +// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到 +// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。 interface ReaderAudioPanelProps { src: string title: string + /** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */ + cover: string theme: { bg: string; text: string; accent: string } initialPos: number // 恢复进度(秒) + /** 片头跳过秒数(Book.openCredits)。 */ + openCredits: number + /** 片尾跳过秒数(Book.closeCredits)。 */ + closeCredits: number + chapters: ReaderChapter[] + chapterIndex: number | null + /** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */ + hasNext: boolean + /** 该音轨由服务端转码,首次播放需要等转码完成。 */ + transcoding?: boolean onProgress: (seconds: number) => void onPrevChapter: () => void onNextChapter: () => void + onSelectChapter: (index: number) => void onEnded: () => void + onCreditsChange: (open: number, close: number) => void onToggleMenu: () => void } -const RATES = [0.75, 1, 1.25, 1.5, 2] +/** -15s / +15s 步长(legado AudioPlayActivity.SEEK_STEP)。 */ +const SEEK_STEP = 15 +/** 片头片尾滑杆步进(秒)。 */ +const CREDIT_STEP = 5 +/** 动作行高度 76px:抽屉与遮罩都从动作行上沿开始,保证动作行常驻可点。 */ +const ACTION_BAR_BOTTOM = 'bottom-[76px]' + +type Sheet = 'none' | 'chapters' | 'timer' | 'speed' | 'credits' function fmt(sec: number): string { - if (!Number.isFinite(sec)) return '0:00' + if (!Number.isFinite(sec) || sec < 0) return '0:00' const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${String(s).padStart(2, '0')}` } +function fmtCountdown(sec: number): string { + const m = Math.floor(sec / 60) + const s = sec % 60 + return `${m}:${String(s).padStart(2, '0')}` +} + export function ReaderAudioPanel({ src, title, + cover, theme, initialPos, + openCredits, + closeCredits, + chapters, + chapterIndex, + hasNext, + transcoding = false, onProgress, onPrevChapter, onNextChapter, + onSelectChapter, onEnded, + onCreditsChange, onToggleMenu, }: ReaderAudioPanelProps) { const audioRef = useRef(null) const hlsRef = useRef<{ destroy: () => void } | null>(null) const restoredRef = useRef(false) + const skippedEndRef = useRef(false) + + const speed = useReaderSettingsStore((s) => s.audioSpeed) + const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed) + const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes) + const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes) + const [playing, setPlaying] = useState(false) const [cur, setCur] = useState(0) const [dur, setDur] = useState(0) - const [rateIdx, setRateIdx] = useState(1) const [loading, setLoading] = useState(true) + // 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」 + const [audioError, setAudioError] = useState('') + const [sheet, setSheet] = useState('none') + // 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图 + const [coverOK, setCoverOK] = useState(true) + useEffect(() => { + setCoverOK(true) + }, [cover]) - // 换源/换章:重建播放器 + // 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute) + const [timerLeft, setTimerLeft] = useState(0) + const [timerActive, setTimerActive] = useState(false) + const [timerPreset, setTimerPreset] = useState(defaultTimer) + const timerDeadlineRef = useRef(0) + + // 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库) + const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits }) + useEffect(() => { + setCreditDraft({ open: openCredits, close: closeCredits }) + }, [openCredits, closeCredits]) + + // ── 换源/换章:重建播放器 ── useEffect(() => { const audio = audioRef.current if (!audio) return restoredRef.current = false + skippedEndRef.current = false setLoading(true) + setAudioError('') + setCur(0) + setDur(0) let cancelled = false let hls: { destroy: () => void } | null = null @@ -87,9 +178,57 @@ export function ReaderAudioPanel({ useEffect(() => { const audio = audioRef.current - if (audio) audio.playbackRate = RATES[rateIdx] - }, [rateIdx]) + if (audio) audio.playbackRate = speed + }, [speed]) + // ── 定时关闭 ── + const applyTimer = useCallback( + (minutes: number) => { + setDefaultTimer(minutes) + setTimerPreset(minutes) + if (minutes <= 0) { + timerDeadlineRef.current = 0 + setTimerLeft(0) + setTimerActive(false) + return + } + timerDeadlineRef.current = Date.now() + minutes * 60_000 + setTimerLeft(minutes * 60) + setTimerActive(true) + }, + [setDefaultTimer], + ) + + // 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer)) + const timerBootRef = useRef(false) + useEffect(() => { + if (timerBootRef.current) return + timerBootRef.current = true + if (defaultTimer > 0) applyTimer(defaultTimer) + }, [applyTimer, defaultTimer]) + + useEffect(() => { + if (!timerActive) return + const id = window.setInterval(() => { + const audio = audioRef.current + // 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延 + if (audio && audio.paused) { + timerDeadlineRef.current += 1000 + return + } + const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000)) + setTimerLeft(left) + if (left <= 0) { + timerDeadlineRef.current = 0 + setTimerActive(false) + audio?.pause() + toast('定时结束,已暂停播放') + } + }, 1000) + return () => window.clearInterval(id) + }, [timerActive]) + + // ── 播放控制 ── const toggle = () => { const audio = audioRef.current if (!audio) return @@ -97,85 +236,502 @@ export function ReaderAudioPanel({ else audio.pause() } + const seekBy = useCallback((delta: number) => { + const audio = audioRef.current + if (!audio) return + const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta) + const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2)) + audio.currentTime = next + setCur(next) + }, []) + + const seekTo = useCallback((seconds: number) => { + const audio = audioRef.current + if (!audio) return + audio.currentTime = seconds + setCur(seconds) + }, []) + + const changeSpeed = (v: number) => { + setSpeed(v) + } + + const saveCredits = (open: number, close: number) => { + setCreditDraft({ open, close }) + onCreditsChange(open, close) + } + + const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' } + + const chipClass = (active: boolean) => + `rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${active ? '' : 'opacity-70'}` + + const chipStyle = (active: boolean) => ({ + borderColor: active ? theme.accent : theme.text + '33', + color: active ? theme.accent : theme.text, + backgroundColor: active ? theme.accent + '1a' : 'transparent', + }) + + const closeSheet = () => setSheet('none') + + const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next)) + + const chapterList = useMemo( + () => chapters.map((c, i) => ({ ...c, i })), + [chapters], + ) + return ( - - {/* 封面占位(居中装饰) */} - - - - - {title} - - - {playing ? '播放中' : loading ? '加载中…' : '已暂停'} · 倍速 {RATES[rateIdx]}x - + { + if (sheet !== 'none') closeSheet() + else onToggleMenu() + }} + > + {/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。 + 蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */} + {cover && coverOK && ( + <> + + + > + )} - {/* 进度条 */} - - {fmt(cur)} - { - const v = Number(e.target.value) - setCur(v) - if (audioRef.current) audioRef.current.currentTime = v - }} - className="flex-1" - style={{ accentColor: theme.accent }} - /> - {fmt(dur)} - - - {/* transport 行(上一章 / 播放暂停 / 下一章 / 倍速) */} - e.stopPropagation()}> - - 上一章 - - + {/* 封面(legado ivCover:圆形封面图;无封面/加载失败退回主题色圆点) */} + - {loading ? : playing ? : } - - - 下一章 - + + {cover && coverOK ? ( + setCoverOK(false)} + /> + ) : ( + + )} + + + + + + + {title} + + + {audioError ? ( + + {audioError} + + ) : ( + <> + {loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} · + 倍速 {speed}x + > + )} + {!audioError && timerActive ? ` · 定时 ${fmtCountdown(timerLeft)}` : ''} + {!audioError && (openCredits > 0 || closeCredits > 0) + ? ` · 跳过片头${openCredits}s/片尾${closeCredits}s` + : ''} + + + + {/* 进度条 */} + + {fmt(cur)} + seekTo(Number(e.target.value))} + onClick={(e) => e.stopPropagation()} + className="flex-1" + style={{ accentColor: theme.accent }} + /> + {fmt(dur)} + + + {/* transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章 */} + e.stopPropagation()} + > + + + + seekBy(-SEEK_STEP)} + className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100" + style={{ borderColor: theme.text + '44' }} + aria-label={`后退 ${SEEK_STEP} 秒`} + title={`后退 ${SEEK_STEP} 秒`} + > + + {SEEK_STEP}s + + + {loading ? ( + + ) : playing ? ( + + ) : ( + + )} + + seekBy(SEEK_STEP)} + className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100" + style={{ borderColor: theme.text + '44' }} + aria-label={`前进 ${SEEK_STEP} 秒`} + title={`前进 ${SEEK_STEP} 秒`} + > + + {SEEK_STEP}s + + + + + - setRateIdx((i) => (i + 1) % RATES.length)} - className="rounded-xl border px-3 py-1 text-2xs font-bold opacity-80 hover:opacity-100" - style={{ borderColor: theme.text + '44', color: theme.text }} + + {/* 动作行:常驻底部,抽屉打开时仍可点(可在几个抽屉间直接切换) */} + e.stopPropagation()} > - {RATES[rateIdx]}x - + {( + [ + { + icon: , + label: '章节', + active: sheet === 'chapters', + action: () => openSheet('chapters'), + }, + { + icon: , + label: timerActive ? fmtCountdown(timerLeft) : '定时', + active: timerActive || sheet === 'timer', + action: () => openSheet('timer'), + }, + { + icon: , + label: `${speed}x`, + active: speed !== 1 || sheet === 'speed', + action: () => openSheet('speed'), + }, + { + icon: , + label: '片头片尾', + active: openCredits > 0 || closeCredits > 0 || sheet === 'credits', + action: () => openSheet('credits'), + }, + ] as const + ).map((item) => ( + + {item.icon} + {item.label} + + ))} + + + {/* ── 底部抽屉(到动作行上沿为止,动作行始终可点) ── */} + {sheet !== 'none' && ( + <> + + e.stopPropagation()} + > + {sheet === 'chapters' && ( + + + 章节({chapters.length}) + + + + + + { + const isCurrent = row.i === chapterIndex + return ( + { + if (row.is_volume) return + closeSheet() + onSelectChapter(row.i) + }} + className={`block w-full truncate px-4 py-2.5 text-left text-xs ${ + row.is_volume ? 'font-bold opacity-70' : '' + }`} + style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined} + > + {row.title} + + ) + }} + /> + + + )} + + {sheet === 'timer' && ( + + 定时关闭 + + {AUDIO_TIMERS.map((m) => { + const active = timerActive ? timerPreset === m : m === 0 + return ( + applyTimer(m)} + className={chipClass(active)} + style={chipStyle(active)} + > + {m === 0 ? '关闭定时' : `${m} 分钟`} + + ) + })} + + + {timerActive ? `剩余 ${fmtCountdown(timerLeft)},播完自动暂停` : '未开启:不会自动停止播放'} + + + )} + + {sheet === 'speed' && ( + + + 播放倍速 + + {speed}x + + + changeSpeed(Number(e.target.value))} + className="w-full" + style={{ accentColor: theme.accent }} + /> + + {AUDIO_SPEEDS.map((v) => ( + changeSpeed(v)} + className={chipClass(v === speed)} + style={chipStyle(v === speed)} + > + {v}x + + ))} + + + )} + + {sheet === 'credits' && ( + + + 跳过片头片尾 + saveCredits(0, 0)} + className="text-2xs opacity-70 hover:opacity-100" + > + 重置 + + + + + + 片头 + + {creditDraft.open} 秒 + + + setCreditDraft((d) => ({ ...d, open: Number(e.target.value) }))} + onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)} + onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)} + className="w-full" + style={{ accentColor: theme.accent }} + /> + + + + 片尾 + + {creditDraft.close} 秒 + + + setCreditDraft((d) => ({ ...d, close: Number(e.target.value) }))} + onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)} + onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)} + className="w-full" + style={{ accentColor: theme.accent }} + /> + + + + {[0, 15, 30, 45, 60, 90].map((v) => ( + saveCredits(v, creditDraft.close)} + className={chipClass(v === creditDraft.open)} + style={chipStyle(v === creditDraft.open)} + > + 片头 {v}s + + ))} + + + 每章从头播放时跳过前 {creditDraft.open} 秒;剩最后 {creditDraft.close} 秒时视为本章播完。 + + + )} + + > + )} setPlaying(true)} onPause={() => setPlaying(false)} - onLoadedMetadata={(e) => { - setDur(e.currentTarget.duration) + onError={(e) => { + // 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。 + // 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。 + const code = e.currentTarget.error?.code setLoading(false) - if (!restoredRef.current && initialPos > 0 && initialPos < e.currentTarget.duration) { - e.currentTarget.currentTime = initialPos + setAudioError( + code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)' + : code === MediaError.MEDIA_ERR_NETWORK + ? '音频加载失败,请检查网络或源文件是否还在' + : '音频无法播放', + ) + }} + onLoadedMetadata={(e) => { + const audio = e.currentTarget + setDur(audio.duration) + setLoading(false) + audio.playbackRate = speed + if (!restoredRef.current) { + const total = Number.isFinite(audio.duration) ? audio.duration : 0 + if (initialPos > 0 && (total === 0 || initialPos < total)) { + // 有进度:按存档续播(legado: position != 0 时不套用片头) + audio.currentTime = initialPos + setCur(initialPos) + } else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) { + // 全新开播:跳到片头结束位置(legado: skipStartMs) + audio.currentTime = openCredits + setCur(openCredits) + } } restoredRef.current = true - void e.currentTarget.play().catch(() => undefined) + void audio.play().catch(() => undefined) }} onTimeUpdate={(e) => { - setCur(e.currentTarget.currentTime) - onProgress(e.currentTarget.currentTime) + const audio = e.currentTarget + const t = audio.currentTime + setCur(t) + onProgress(t) + // 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完) + if ( + closeCredits > 0 && + !skippedEndRef.current && + Number.isFinite(audio.duration) && + audio.duration > closeCredits + 1 && + t >= audio.duration - closeCredits + ) { + skippedEndRef.current = true + if (!hasNext) { + audio.pause() + audio.currentTime = Math.max(0, audio.duration - 0.5) + return + } + onEnded() + } }} onEnded={onEnded} /> diff --git a/web/src/pages/reader/ReaderHomeContent.tsx b/web/src/pages/reader/ReaderHomeContent.tsx index 657330d..3923efa 100644 --- a/web/src/pages/reader/ReaderHomeContent.tsx +++ b/web/src/pages/reader/ReaderHomeContent.tsx @@ -1,17 +1,52 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { BookOpen, Loader2, RefreshCw, Search, Settings2 } from 'lucide-react' +import toast from 'react-hot-toast' +import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react' import { readerAPI, type ReaderBook } from '../../api/reader' +import { confirmAction } from '../../components/confirmAction' +import { useAuthStore } from '../../stores/auth' import { ReaderModeSwitch } from './ReaderModeSwitch' +import { ServerFilePickerDialog } from './ServerFilePickerDialog' // 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。 -// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上搜索/书源管理入口。 +// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。 + +// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。 +// total_chapter_num 为 0 表示目录尚未缓存,无法计算。 +function unreadChapters(book: ReaderBook): number | null { + if (book.total_chapter_num <= 0) return null + const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0 + return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read)) +} export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) { const navigate = useNavigate() + const isAdmin = useAuthStore((state) => state.user?.role === 'admin') const [books, setBooks] = useState(null) const [error, setError] = useState('') + const [uploading, setUploading] = useState(null) // 上传进度百分比 + const [picker, setPicker] = useState<'book' | 'audio' | null>(null) + const [menuOpen, setMenuOpen] = useState(false) + const fileRef = useRef(null) + const menuRef = useRef(null) + + // 下拉菜单:点外部或按 Esc 关闭 + useEffect(() => { + if (!menuOpen) return + const onPointerDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false) + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false) + } + document.addEventListener('mousedown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [menuOpen]) const load = () => { setError('') @@ -21,28 +56,153 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) .catch((e) => setError(e?.response?.data?.error ?? '加载书架失败')) } - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(load, []) + const upload = async (file: File) => { + setUploading(0) + try { + const book = await readerAPI.uploadLocalBook(file, setUploading) + toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`) + load() + navigate(`/reader/view/${book.id}`) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败') + } finally { + setUploading(null) + if (fileRef.current) fileRef.current.value = '' + } + } + + // 移出书架:所有书籍都可移除。本地导入的书会连同服务器文件一起删除, + // 原地引用的只解除引用,书源书籍只清掉书架记录与阅读进度。 + const removeFromShelf = async (book: ReaderBook) => { + const message = book.local_external + ? `《${book.name}》是原地引用服务器上的文件,移出书架只解除引用,不会删除源文件。确定吗?` + : book.is_local + ? `《${book.name}》及其在服务器上的文件都会被删除,确定吗?` + : `《${book.name}》会连同阅读进度一起从书架移除,不影响书源。确定吗?` + const ok = await confirmAction({ + title: '移出书架', + message, + confirmText: '移出', + danger: true, + }) + if (!ok) return + try { + await readerAPI.removeBook(book.id) + setBooks((prev) => (prev ? prev.filter((b) => b.id !== book.id) : prev)) + toast.success('已移出书架') + } catch { + toast.error('移出失败') + } + } + + const runPicker = async (path: string) => { + const kind = picker + setPicker(null) + try { + const book = + kind === 'audio' + ? await readerAPI.importLocalAudioDir(path) + : await readerAPI.importLocalBookFromPath(path) + toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`) + load() + navigate(`/reader/view/${book.id}`) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败') + } + } + return ( {!embedded && } + {/* 标题右侧一个下拉入口,收纳全部书架操作 */} - 书架 - - 书架 + + { + const f = e.target.files?.[0] + if (f) void upload(f) + }} + /> + setMenuOpen((v) => !v)} + className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]" + title="书架操作" > - 书源管理 - - - 搜索 - + {uploading !== null ? : } + {uploading !== null ? `上传中 ${uploading}%` : '管理'} + + + + {menuOpen && ( + + { + setMenuOpen(false) + fileRef.current?.click() + }} + className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60" + > + 本地导入 + + {isAdmin && ( + <> + { + setMenuOpen(false) + setPicker('book') + }} + className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60" + > + 服务器导入 + + { + setMenuOpen(false) + setPicker('audio') + }} + className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60" + > + 有声书导入 + + > + )} + + setMenuOpen(false)} + className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]" + > + 书源管理 + + + )} @@ -67,48 +227,113 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) 书架空空如也 - 先到「书源管理」导入书源,再搜索加入书架 + 上传本地 TXT / EPUB,或导入书源后搜索加入书架 + fileRef.current?.click()} + className="btn-outline text-xs disabled:opacity-60" + > + {uploading !== null ? `上传中 ${uploading}%` : '上传本地书籍'} + 导入书源 去搜索 + {isAdmin && ( + + setPicker('book')} className="btn-outline whitespace-nowrap text-xs"> + 从服务器导入 + + setPicker('audio')} className="btn-outline whitespace-nowrap text-xs"> + 从服务器导入有声书 + + + )} )} {books !== null && books.length > 0 && ( - {books.map((book) => ( - navigate(`/reader/view/${book.id}`)} - className="group text-left" - > - - - {book.cover_url ? ( - - ) : ( - - - + {books.map((book) => { + const unread = unreadChapters(book) + return ( + + navigate(`/reader/view/${book.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + navigate(`/reader/view/${book.id}`) + } + }} + className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500" + > + + {book.cover_url ? ( + + ) : ( + + + + )} + + {/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */} + {book.is_local && ( + + 本地 + )} + {unread !== null && ( + 0 ? `还有 ${unread} 章未读` : '已读完'} + className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${ + unread > 0 ? 'bg-rose-500/90' : 'bg-black/55' + }`} + > + {unread > 0 ? `${unread} 章未读` : '已读完'} + + )} + { + e.stopPropagation() + void removeFromShelf(book) + }} + className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" + > + + + {book.name} + + {book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'} + - {book.name} - - {book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'} - - - ))} + ) + })} )} + + {picker && ( + void runPicker(path)} + onClose={() => setPicker(null)} + /> + )} ) } diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx index 97863dd..c8969ad 100644 --- a/web/src/pages/reader/ReaderSearchPage.tsx +++ b/web/src/pages/reader/ReaderSearchPage.tsx @@ -1,5 +1,5 @@ -import { useRef, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import toast from 'react-hot-toast' import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react' @@ -9,7 +9,8 @@ import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../. export default function ReaderSearchPage() { const navigate = useNavigate() - const [key, setKey] = useState('') + const [params] = useSearchParams() + const [key, setKey] = useState(() => params.get('key') ?? '') const [searching, setSearching] = useState(false) const [books, setBooks] = useState(null) const [skipped, setSkipped] = useState([]) @@ -24,9 +25,11 @@ export default function ReaderSearchPage() { setShowSkipped(false) try { const res = await readerAPI.search(kw) - setBooks(res.books) - setSkipped(res.skipped) - if (res.books.length === 0) toast.error('所有书源都没有找到结果') + setBooks(res.books ?? []) + // 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组, + // 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。 + setSkipped(res.skipped ?? []) + if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果') } catch (e) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败' toast.error(msg) @@ -35,8 +38,16 @@ export default function ReaderSearchPage() { } } - const addToShelf = async (book: ReaderSearchBook) => { - const origin = book.origins[0] +// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜) + const autoSearchedRef = useRef(false) + useEffect(() => { + if (autoSearchedRef.current) return + autoSearchedRef.current = true + if (key.trim()) void doSearch() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0] if (!origin) return setAdding(book.book_url) try { diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index d2e2c4c..ab077b0 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import toast from 'react-hot-toast' import { @@ -25,9 +25,28 @@ import { ReaderComic } from './ReaderComic' const COLUMN_GAP = 48 +/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */ +const IMG_MARK = '[img]' + +/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */ +const MENU_SHIFT = 'transition-transform duration-200' + +// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素) +/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */ +const WHEEL_NOTCH = 40 +/** 触控板小步长累计到这个量翻一页。 */ +const WHEEL_SWIPE_THRESHOLD = 60 +/** 两次翻页的最小间隔,与 220ms 翻页动画对齐。 */ +const WHEEL_TURN_COOLDOWN = 220 +/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */ +const WHEEL_GESTURE_GAP = 180 + function firstReadableIndex(chapters: ReaderChapter[]): number { const i = chapters.findIndex((c) => !c.is_volume && c.url) - return i === -1 ? 0 : i + if (i !== -1) return i + // 本地导入的章节没有 url,退回第一个非卷名章 + const j = chapters.findIndex((c) => !c.is_volume) + return j === -1 ? 0 : j } export default function ReaderViewPage() { @@ -45,6 +64,15 @@ export default function ReaderViewPage() { const [menuOpen, setMenuOpen] = useState(false) const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none') + // 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行 + const topBarRef = useRef(null) + const [menuInset, setMenuInset] = useState(0) + // 滚轮翻页的累计量 / 冷却 / 一次手势只翻一页的锁 + const readerRef = useRef(null) + const wheelAccumRef = useRef(0) + const wheelLastEventRef = useRef(0) + const wheelLastTurnRef = useRef(0) + const wheelSwipeLockedRef = useRef(false) // 分页状态 const viewportRef = useRef(null) @@ -78,6 +106,7 @@ export default function ReaderViewPage() { setBook(b) let chs = await readerAPI.listChapters(b.id) if (chs.length === 0) { + if (b.is_local) throw new Error('本地书籍目录为空,请重新导入该书') const toc = await readerAPI.toc({ source_url: b.origin, book_url: b.book_url, @@ -90,7 +119,9 @@ export default function ReaderViewPage() { } if (cancelled) return setChapters(chs) - const qChapter = Number(new URLSearchParams(window.location.search).get('chapter') ?? '') + // URL 上带 chapter 才用它;没有这个参数就不能当成 0,否则每次进来都回第一章 + const qChapterRaw = new URLSearchParams(window.location.search).get('chapter') + const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN let idx = firstReadableIndex(chs) if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) { @@ -276,6 +307,33 @@ export default function ReaderViewPage() { [chapterIndex, chapters], ) + // 听书:进度条/章节列表跳章(不沿用 goChapter 的越界提示,直接落位) + const jumpToChapter = useCallback((idx: number) => { + if (idx < 0 || idx >= chapters.length) return + pendingEndRef.current = false + setError('') + setChapterIndex(idx) + }, [chapters.length]) + + // 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住) + const hasNextAudioChapter = useMemo(() => { + if (chapterIndex === null) return false + for (let i = chapterIndex + 1; i < chapters.length; i++) { + if (!chapters[i].is_volume) return true + } + return false + }, [chapterIndex, chapters]) + + // 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits) + const saveAudioCredits = useCallback( + (open: number, close: number) => { + if (!book) return + setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev)) + readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined) + }, + [book], + ) + const goPrev = useCallback(() => { if (contentType === 'audio') { goChapter(-1) @@ -334,8 +392,70 @@ export default function ReaderViewPage() { return () => window.removeEventListener('keydown', onKey) }, [goPrev, goNext, menuOpen, panel]) + // ── 鼠标滚轮翻页(仅翻页模式) ── + // 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。 + // 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。 + useEffect(() => { + const el = readerRef.current + if (!el) return + // 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页 + if (settings.pageMode !== 'page' || menuOpen) return + if (contentType !== 'text' && contentType !== 'image') return + + const turn = (forward: boolean) => (forward ? goNext() : goPrev()) + const onWheel = (e: WheelEvent) => { + // ctrl+滚轮是浏览器缩放,别抢 + if (e.ctrlKey || e.metaKey || e.deltaY === 0) return + e.preventDefault() + const now = Date.now() + // deltaMode: 0=像素 1=行 2=页,统一折算成像素 + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1 + const delta = e.deltaY * unit + if (now - wheelLastEventRef.current > WHEEL_GESTURE_GAP) { + wheelAccumRef.current = 0 + wheelSwipeLockedRef.current = false + } + wheelLastEventRef.current = now + + if (Math.abs(delta) >= WHEEL_NOTCH) { + // 鼠标滚轮:一格一页,但至少隔一次翻页动画的时间 + if (now - wheelLastTurnRef.current < WHEEL_TURN_COOLDOWN) return + wheelLastTurnRef.current = now + wheelAccumRef.current = 0 + turn(delta > 0) + return + } + // 触控板:小步长累计到阈值再翻,一次手势只翻一页,避免惯性连翻 + if (wheelSwipeLockedRef.current) return + wheelAccumRef.current += delta + if (Math.abs(wheelAccumRef.current) < WHEEL_SWIPE_THRESHOLD) return + const forward = wheelAccumRef.current > 0 + wheelAccumRef.current = 0 + wheelSwipeLockedRef.current = true + wheelLastTurnRef.current = now + turn(forward) + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + }, [settings.pageMode, menuOpen, contentType, goPrev, goNext]) + const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null + // 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。 + // 用 transform 而不是 padding,避免改变视口高度触发重新分页。 + const menuShiftStyle = useMemo( + () => (menuOpen && menuInset > 0 ? { transform: `translateY(${menuInset}px)` } : undefined), + [menuOpen, menuInset], + ) + useLayoutEffect(() => { + if (!menuOpen) { + setMenuInset(0) + return + } + const h = topBarRef.current?.offsetHeight ?? 0 + setMenuInset((cur) => (cur === h ? cur : h)) + }, [menuOpen]) + // 菜单进度条按内容类型适配:文本=页/滚动位置,音频=章节,漫画=图片序号 const imageCount = media?.images?.length ?? 0 const sliderCfg = (() => { @@ -344,7 +464,7 @@ export default function ReaderViewPage() { min: 0, max: Math.max(0, chapters.length - 1), value: Math.max(0, chapterIndex ?? 0), - onChange: (v: number) => setChapterIndex(v), + onChange: (v: number) => jumpToChapter(v), } } if (contentType === 'image') { @@ -376,6 +496,27 @@ export default function ReaderViewPage() { })() const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean) + // 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片 + const renderParagraph = (line: string, key: number) => { + if (line.startsWith(IMG_MARK)) { + return ( + + + + ) + } + return ( + + {line} + + ) + } + // ── 渲染 ── if (error && !book) { return ( @@ -389,7 +530,7 @@ export default function ReaderViewPage() { } return ( - + {/* 正文视口 */} @@ -398,12 +539,21 @@ export default function ReaderViewPage() { goChapter(-1)} onNextChapter={() => goChapter(1)} + onSelectChapter={jumpToChapter} onEnded={() => goChapter(1)} + onCreditsChange={saveAudioCredits} onToggleMenu={() => setMenuOpen((v) => !v)} /> ) : ( @@ -429,25 +579,26 @@ export default function ReaderViewPage() { onScrolled={() => setScrollToImage(null)} /> ) : settings.pageMode === 'page' ? ( - - - {paragraphs.map((p, i) => ( - - {p} - - ))} + /* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边; + 菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform, + 不改高度也就不触发重新分页) */ + + + + {paragraphs.map((p, i) => renderParagraph(p, i))} + ) : ( @@ -465,15 +616,11 @@ export default function ReaderViewPage() { } } }} - className="h-full overflow-y-auto px-1" - style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight }} + className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`} + style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }} > - {paragraphs.map((p, i) => ( - - {p} - - ))} + {paragraphs.map((p, i) => renderParagraph(p, i))} )} @@ -541,6 +688,7 @@ export default function ReaderViewPage() { /> {/* 顶栏 */} @@ -619,46 +767,6 @@ export default function ReaderViewPage() { ))} - {/* 目录抽屉 */} - {panel === 'toc' && ( - - - - 目录({chapters.length} 章) - - - { - const idx = chapters.indexOf(ch) - const isCurrent = idx === chapterIndex - return ( - { - setChapterIndex(idx) - setPanel('none') - setMenuOpen(false) - }} - className={`block w-full truncate px-4 py-2.5 text-left text-xs ${ - ch.is_volume ? 'font-bold opacity-70' : '' - }`} - style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined} - > - {ch.title} - - ) - }} - /> - - - - )} - {/* 界面设置面板(主题 / 字号 / 行距 / 段距) */} {panel === 'style' && ( - - + + 字号 settings.setFontSize(settings.fontSize - 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}> @@ -692,7 +800,7 @@ export default function ReaderViewPage() { - + 行距 settings.setLineHeight(settings.lineHeight - 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}> @@ -702,7 +810,7 @@ export default function ReaderViewPage() { - + 段距 settings.setParagraphSpacing(settings.paragraphSpacing - 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}> @@ -716,6 +824,58 @@ export default function ReaderViewPage() { )} + + {/* 目录:整屏面板。必须放在底部菜单之外——菜单带 backdrop-blur, + 会成为 fixed 后代的包含块,放里面高度会被算成菜单的高度。 */} + {panel === 'toc' && ( + + + setPanel('none')} + className="rounded-xl p-1.5 opacity-70 hover:opacity-100" + aria-label="收起目录" + > + + + + {book?.name ?? '目录'} + 目录({chapters.length} 章) + + + + { + const isCurrent = index === chapterIndex + return ( + { + setPanel('none') + setMenuOpen(false) + jumpToChapter(index) + }} + className={`block w-full truncate px-4 py-2.5 text-left text-xs ${ + ch.is_volume ? 'font-bold opacity-70' : '' + }`} + style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined} + > + {ch.title} + + ) + }} + /> + + + )} > )} diff --git a/web/src/pages/reader/ServerFilePickerDialog.tsx b/web/src/pages/reader/ServerFilePickerDialog.tsx new file mode 100644 index 0000000..fdd81ae --- /dev/null +++ b/web/src/pages/reader/ServerFilePickerDialog.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronRight, FileAudio, FileText, Folder, FolderPlus, HardDrive, Loader2, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { filesAPI, type FileEntry } from '../../api/files' + +function apiErrorMessage(err: unknown): string { + if (typeof err === 'object' && err !== null && 'response' in err) { + const res = (err as { response?: { data?: { error?: string; message?: string } } }).response + if (res?.data?.error) return res.data.error + if (res?.data?.message) return res.data.message + } + if (err instanceof Error) return err.message + return '请求失败' +} + +function formatSize(bytes: number): string { + if (!bytes) return '' + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}` +} + +/** + * ServerFilePickerDialog 选择服务器上已有的文件或目录。 + * + * 走 /api/files(仅管理员,且只能浏览已配置的存储根目录),供阅读模块 + * 「从服务器导入书籍 / 有声书」使用。 + */ +export function ServerFilePickerDialog({ + mode, + extensions, + title, + initialDir, + onSelect, + onClose, +}: { + mode: 'file' | 'dir' + /** mode=file 时限制可选的后缀,小写带点(如 ['.txt', '.epub']);留空表示不限制 */ + extensions?: string[] + title: string + initialDir?: string + onSelect: (path: string) => void + onClose: () => void +}) { + const [listing, setListing] = useState<{ + path: string + parent?: string + roots?: { label: string; path: string }[] + entries: FileEntry[] | null + } | null>(null) + const [loading, setLoading] = useState(true) + + const load = useCallback(async (target: string) => { + setLoading(true) + try { + const data = await filesAPI.list(target) + setListing({ path: data.path, parent: data.parent, roots: data.roots, entries: data.entries }) + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load(initialDir ?? '') + }, [initialDir, load]) + + const atRoot = !listing?.path + // 后端返回的 ext 不带点("epub"),这里统一去掉点再比较 + const normalizeExt = (raw?: string) => (raw ?? '').toLowerCase().replace(/^\./, '') + const wanted = (extensions ?? []).map(normalizeExt) + const accept = (entry: FileEntry) => { + if (entry.is_dir) return true + if (mode === 'dir') return false + if (wanted.length === 0) return true + return wanted.includes(normalizeExt(entry.ext)) + } + + const roots = listing?.roots ?? [] + const entries = (listing?.entries ?? []).filter(accept) + // 根目录列表和目录内条目是两个来源,空态判断要分别看 + const itemCount = atRoot ? roots.length : entries.length + const canPickCurrent = mode === 'dir' && !atRoot + + return ( + + event.stopPropagation()} + > + + {title} + + + + + + + {atRoot ? ( + 选择存储位置 + ) : ( + <> + load('')}> + 存储位置 + + + + {listing?.path} + + > + )} + + + + {loading ? ( + + + + ) : itemCount === 0 ? ( + + {atRoot + ? '没有可用的存储位置' + : mode === 'dir' + ? '该目录下没有子目录,可直接选择当前目录' + : '该目录下没有可选文件'} + + ) : ( + + {atRoot ? ( + roots.map((root) => ( + load(root.path)} + > + + {root.label} + {root.path} + + )) + ) : ( + <> + {(listing?.parent ?? '') !== '' && ( + load(listing?.parent ?? '')} + > + + ..(上级目录) + + )} + {entries.map((entry) => ( + (entry.is_dir ? load(entry.path) : onSelect(entry.path))} + > + {entry.is_dir ? ( + + ) : normalizeExt(entry.ext) === 'strm' ? ( + + ) : ( + + )} + {entry.name} + {!entry.is_dir && {formatSize(entry.size)}} + + ))} + > + )} + + )} + + + + + {atRoot + ? '先选择一个存储位置' + : mode === 'dir' + ? '单击目录进入下一级,点击「选择当前目录」完成选择' + : '单击目录进入下一级,单击文件即完成选择'} + + {canPickCurrent && ( + onSelect(listing?.path ?? '')}> + 选择当前目录 + + )} + + + + ) +} diff --git a/web/src/pages/reader/SourceLoginDialog.tsx b/web/src/pages/reader/SourceLoginDialog.tsx index 94c6733..a69190a 100644 --- a/web/src/pages/reader/SourceLoginDialog.tsx +++ b/web/src/pages/reader/SourceLoginDialog.tsx @@ -1,15 +1,17 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react' -import { readerAPI, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader' +import { readerAPI, type ReaderBrowserPage, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader' +import BrowserPanel from './BrowserPanel' // 书源登录面板(仿 legado SourceLoginDialog)。 // // 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑, // 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。 -// 服务端没有弹窗,java.toast/longToast 的提示与 java.startBrowser 的地址 -// 都通过返回值回传,这里负责展示并代为打开浏览器。 +// 服务端没有弹窗,java.toast/longToast 的提示通过返回值回传展示; +// java.startBrowser / startBrowserAwait 则把页面登记成「待办」,这里轮询到后 +// 用内嵌 iframe 承载(见 BrowserPanel),用户的「√」会把 DOM 回传给书源。 interface Props { sourceId: string @@ -38,6 +40,10 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog const [variable, setVariable] = useState('') const [showVariable, setShowVariable] = useState(false) const [showLoginJS, setShowLoginJS] = useState(false) + // 书源交给宿主浏览器承载的页面(java.startBrowser / startBrowserAwait) + const [browserPage, setBrowserPage] = useState(null) + // 已处理过的页面 ID:避免轮询把刚关掉的页面又弹出来 + const handledPages = useRef>(new Set()) const load = useCallback(async () => { setLoading(true) @@ -58,33 +64,46 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog void load() }, [load]) + // 轮询书源登记的待办页面。 + // java.startBrowserAwait 会阻塞在服务端,前端必须在动作执行期间去取页面, + // 否则用户永远看不到「切换线路」「用户后台」这些按钮真正要展示的东西。 + const pollBrowserPages = useCallback(async () => { + try { + const pages = await readerAPI.browserPending(sourceId) + const next = pages.find((p) => !handledPages.current.has(p.id)) + if (next) setBrowserPage(next) + } catch { + // 轮询失败不打断正在执行的动作 + } + }, [sourceId]) + // 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。 const buttons = useMemo(() => info?.fields.filter((f) => f.type === 'button') ?? [], [info]) const inputs = useMemo(() => info?.fields.filter((f) => f.type !== 'button') ?? [], [info]) const selects = useMemo(() => info?.fields.filter((f) => f.type === 'toggle' || f.type === 'select') ?? [], [info]) - const runAction = async (action: string, opts: { saveFields?: boolean } = {}) => { + const runAction = async (action: string, opts: { fieldsOverride?: Record } = {}) => { const key = action || '__login__' + // 注意用 fieldsOverride 而不是闭包里的 form:select/toggle 的 onPick 里 + // setForm 是异步的,同一轮事件里读 form 拿到的还是旧值, + // 会让书源按旧线路执行(表现为「切了但没生效」)。 + const fields = opts.fieldsOverride ?? form setRunning(key) setToasts([]) setError('') + // 动作可能阻塞等待人工操作,期间持续轮询待办页面 + const poll = window.setInterval(() => void pollBrowserPages(), 600) try { - const res = await readerAPI.runSourceLogin(sourceId, { action, fields: form }) + const res = await readerAPI.runSourceLogin(sourceId, { action, fields }) const messages = [...(res.toasts ?? [])] if (res.error) messages.push(res.error) - setToasts(messages) - if (res.browsers?.length) { - // 服务端无法弹窗:把需要人工操作的页面提示给用户 - for (const b of res.browsers) { - messages.push(`需要浏览器操作:${b.title || b.url}`) - } - setToasts([...messages]) + // 服务端未注入宿主浏览器时(非登录链路),这里退化为提示 + 可打开的地址 + for (const b of res.browsers ?? []) { + messages.push(`需要浏览器操作:${b.title || ''} ${b.url}`.trim()) } + setToasts(messages) // 同步回最新登录信息(书源可能在动作里回填字段) if (res.values) setForm((prev) => ({ ...prev, ...res.values })) - if (opts.saveFields && !res.ok) { - // 动作失败也保留用户输入,便于改完重试 - } onLoggedInChange?.(res.logged_in) const nextInfo = await readerAPI.sourceLogin(sourceId) setInfo(nextInfo) @@ -96,10 +115,20 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog setError(msg) toast.error(msg) } finally { + window.clearInterval(poll) setRunning('') + // 收尾再取一次,避免最后一个待办落在轮询间隙里 + void pollBrowserPages() } } + const closeBrowserPage = useCallback(() => { + setBrowserPage((cur) => { + if (cur) handledPages.current.add(cur.id) + return null + }) + }, []) + const saveLoginInfo = async () => { try { await readerAPI.saveSourceLoginInfo(sourceId, form) @@ -141,7 +170,14 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog const cookieEntries = Object.entries(info?.cookies ?? {}) return ( - + { + if (!browserPage) onClose() + }} + > e.stopPropagation()} @@ -216,8 +252,11 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog value={form[f.name] ?? f.default ?? ''} disabled={!!running} onPick={(v) => { - setForm((prev) => ({ ...prev, [f.name]: v })) - if (f.action) void runAction(f.action) + // 必须把新值显式带进 action:setForm 是异步的, + // runAction 读闭包里的 form 会拿到切换前的旧值。 + const next = { ...form, [f.name]: v } + setForm(next) + if (f.action) void runAction(f.action, { fieldsOverride: next }) }} /> ))} @@ -332,6 +371,9 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog + + {/* 书源页面承载面板:startBrowserAwait 会等这里的「完成」把 DOM 回传 */} + {browserPage && } ) } diff --git a/web/src/stores/readerSettings.ts b/web/src/stores/readerSettings.ts index 7e39b05..69aaf17 100644 --- a/web/src/stores/readerSettings.ts +++ b/web/src/stores/readerSettings.ts @@ -55,8 +55,25 @@ interface ReaderSettingsState { paragraphSpacing: number setParagraphSpacing: (v: number) => void + + // ── 听书(音频源播放器)偏好,对齐 legado AudioPlayService ── + /** 播放倍速(AudioPlay.playSpeed),0.5–3.0,步进 0.1。 */ + audioSpeed: number + setAudioSpeed: (v: number) => void + /** 定时关闭默认分钟数(AppConfig.ttsTimer 语义),0 表示不定时。 */ + audioTimerMinutes: number + setAudioTimerMinutes: (v: number) => void } +/** 听书倍速可选值(legado 是 0.1 步进的浮点,这里收在 0.5–3.0)。 */ +export const AUDIO_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] + +/** 定时关闭预设分钟数(legado ReadAloudDialog times 数组)。 */ +export const AUDIO_TIMERS = [0, 5, 10, 15, 30, 60, 90, 180] + +/** 片头/片尾秒数上限(0 表示不跳过)。 */ +export const AUDIO_CREDITS_MAX = 300 + export const useReaderSettingsStore = create()( persist( (set) => ({ @@ -81,6 +98,14 @@ export const useReaderSettingsStore = create()( paragraphSpacing: 8, setParagraphSpacing: (paragraphSpacing) => set({ paragraphSpacing: Math.min(32, Math.max(0, paragraphSpacing)) }), + + audioSpeed: 1, + setAudioSpeed: (audioSpeed) => + set({ audioSpeed: Math.min(3, Math.max(0.5, Math.round(audioSpeed * 10) / 10)) }), + + audioTimerMinutes: 0, + setAudioTimerMinutes: (audioTimerMinutes) => + set({ audioTimerMinutes: Math.min(180, Math.max(0, Math.round(audioTimerMinutes))) }), }), { name: 'mebox-reader-settings' }, ),
" + htmlEscape(body) + "
第二章正文。
又一段。
图片前。
图片后。
乙丙
正文一。
正文二尾巴。
内容。
+ {query.trim() ? '书架匹配' : '书架'} +
+ {book.name} +
+ {page.title || '书源页面'} +
+ {page.mode === 'wait' ? '操作完成后点右下角「完成」把结果回传给书源' : '确认完毕后关闭即可'} + {page.target_url ? ` · ${page.target_url}` : ''} +
- {title} -
- {playing ? '播放中' : loading ? '加载中…' : '已暂停'} · 倍速 {RATES[rateIdx]}x -
+ {title} +
+ {audioError ? ( + + {audioError} + + ) : ( + <> + {loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} · + 倍速 {speed}x + > + )} + {!audioError && timerActive ? ` · 定时 ${fmtCountdown(timerLeft)}` : ''} + {!audioError && (openCredits > 0 || closeCredits > 0) + ? ` · 跳过片头${openCredits}s/片尾${closeCredits}s` + : ''} +
定时关闭
+ {timerActive ? `剩余 ${fmtCountdown(timerLeft)},播完自动暂停` : '未开启:不会自动停止播放'} +
+ 每章从头播放时跳过前 {creditDraft.open} 秒;剩最后 {creditDraft.close} 秒时视为本章播完。 +
书架空空如也
先到「书源管理」导入书源,再搜索加入书架
上传本地 TXT / EPUB,或导入书源后搜索加入书架
{book.name}
+ {book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'} +
- {book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'} -
+ +
+ {line} +
- {p} -
- 目录({chapters.length} 章) -
+ {book?.name ?? '目录'} + 目录({chapters.length} 章) +
+ {atRoot + ? '没有可用的存储位置' + : mode === 'dir' + ? '该目录下没有子目录,可直接选择当前目录' + : '该目录下没有可选文件'} +