mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
优化阅读功能
This commit is contained in:
@@ -28,6 +28,18 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
|
||||
api.GET("/reader/media", readerMediaProxyHandler(svc))
|
||||
// 本地书籍内嵌资源(EPUB 图片):同样靠 HMAC 签名鉴权。
|
||||
api.GET("/reader/local/asset", readerLocalAssetHandler(svc))
|
||||
// 本地有声书音频流:同样靠 HMAC 签名鉴权,服务端处理 Range。
|
||||
api.GET("/reader/local/audio", readerLocalAudioHandler(svc))
|
||||
api.HEAD("/reader/local/audio", readerLocalAudioHandler(svc))
|
||||
// 需要转码的音轨(WMA 等浏览器解不了的格式):签名鉴权 + 服务端 ffmpeg 转码。
|
||||
api.GET("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
|
||||
api.HEAD("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
|
||||
// 阅读书源浏览器页面与资源代理:java.startBrowserAwait 承载的页面填进
|
||||
// <iframe src>,同样带不了 JWT,鉴权靠绑定待办 ID 的 HMAC 签名。
|
||||
api.GET("/reader/browser/page", readerBrowserPageHandler(svc))
|
||||
api.GET("/reader/browser/asset", readerBrowserAssetHandler(svc))
|
||||
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
|
||||
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 书源宿主浏览器的 HTTP 层测试:轮询待办 → 承载页面 → 回传 DOM。
|
||||
//
|
||||
// 这条链路对应真实书源的「切换线路」「用户后台」:
|
||||
// java.startBrowserAwait 会阻塞在 POST /sources/:id/login 上,
|
||||
// 前端必须能从 /reader/browser/pending 拿到页面,再把 DOM POST 回去。
|
||||
|
||||
// browserHandlerSource 构造一个用 startBrowserAwait 切换线路的书源。
|
||||
func browserHandlerSource(sourceURL string) string {
|
||||
loginJS := `function switchLine() {
|
||||
let html = '<!DOCTYPE html><html><head></head><body><span id="serverValue">线路甲</span></body></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] : ''}));
|
||||
}`
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": sourceURL,
|
||||
"bookSourceName": "浏览器面板源",
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// registerBrowserRoutesForTest 挂载阅读路由 + 承载页面/资源代理(跳过鉴权)。
|
||||
// 生产环境里后两个是公开路由(iframe 带不了 JWT),鉴权靠 HMAC 签名。
|
||||
func registerBrowserRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||
r := registerReaderRoutesForTest(cfg)
|
||||
r.GET("/api/reader/browser/page", readerBrowserPageHandler(cfg))
|
||||
r.GET("/api/reader/browser/asset", readerBrowserAssetHandler(cfg))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestReaderBrowserPanelRoutes(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
// 导入书源
|
||||
body, _ := json.Marshal(map[string]string{"text": browserHandlerSource("https://panel.example.com")})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
var list struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
||||
if len(list.Sources) != 1 {
|
||||
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
|
||||
}
|
||||
sourceID := list.Sources[0].ID
|
||||
|
||||
// 执行切换线路:这个请求会阻塞等待用户操作,放到后台跑
|
||||
actionDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login",
|
||||
strings.NewReader(`{"action":"switchLine()","fields":{}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
actionDone <- rec
|
||||
}()
|
||||
|
||||
// 轮询待办页面
|
||||
var page struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Mode string `json:"mode"`
|
||||
PageURL string `json:"page_url"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("等待超时:未出现待办页面")
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("轮询 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var pending struct {
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||
t.Fatalf("待办返回不是 JSON: %s", w.Body.String())
|
||||
}
|
||||
if len(pending.Pages) > 0 {
|
||||
if err := json.Unmarshal(pending.Pages[0], &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if page.Mode != "wait" || page.Title != "线路设置" {
|
||||
t.Fatalf("待办描述异常: %+v", page)
|
||||
}
|
||||
if page.SourceID != sourceID {
|
||||
t.Fatalf("待办应带书源 ID: %+v", page)
|
||||
}
|
||||
|
||||
// 承载页面:签名正确应返回 HTML,签名错误应 403
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, page.PageURL, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("承载页面 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `id="serverValue"`) {
|
||||
t.Fatalf("承载页面内容异常: %s", w.Body.String())
|
||||
}
|
||||
// 服务端注入的 DOM 回传脚本必须在(父窗口靠它取回用户操作后的页面)
|
||||
if !strings.Contains(w.Body.String(), "data-mebox-browser-bridge") {
|
||||
t.Fatalf("承载页面缺少 DOM 回传脚本: %s", w.Body.String())
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/page?id="+page.ID+"&s=forged", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("伪造签名应 403,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
// 回传用户操作后的 DOM(模拟点了 √)
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"id": page.ID,
|
||||
"body": `<html><body><span id="serverValue">线路乙</span></body></html>`,
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(string(result)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("回传结果 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 阻塞的登录动作应被放行
|
||||
select {
|
||||
case rec := <-actionDone:
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("登录动作 status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
if !res.OK {
|
||||
t.Fatalf("动作未成功: %s", rec.Body.String())
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("回传后阻塞未解除")
|
||||
}
|
||||
|
||||
// 线路应已写进源变量
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
var info struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &info)
|
||||
if !strings.Contains(info.Variable, "线路乙") {
|
||||
t.Fatalf("线路未写入源变量: %q", info.Variable)
|
||||
}
|
||||
|
||||
// 完成后待办清空
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||
var after struct {
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &after)
|
||||
if len(after.Pages) != 0 {
|
||||
t.Fatalf("完成后待办未清理: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderBrowserResultRejectsUnknownPage(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result",
|
||||
strings.NewReader(`{"id":"not-a-real-page","body":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("未知待办应 400,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderBrowserResultRequiresID(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("缺少 id 应 400,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderBrowserPageBadQuery 承载地址参数缺失/非法时不应 500。
|
||||
func TestReaderBrowserPageBadQuery(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
for _, target := range []string{"/api/reader/browser/page", "/api/reader/browser/page?id=x",
|
||||
"/api/reader/browser/asset", "/api/reader/browser/asset?id=x&u=!!!&s=y"} {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s 应 403,实际 %d", target, w.Code)
|
||||
}
|
||||
}
|
||||
// 合法 base64 但不匹配的签名
|
||||
w := httptest.NewRecorder()
|
||||
q := url.Values{"id": {"x"}, "u": {"aHR0cHM6Ly9leGFtcGxlLmNvbS8"}, "s": {"deadbeef"}}
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/asset?"+q.Encode(), nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("签名不匹配应 403,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestReaderLocalAudioStream 本地有声书音频流端点:
|
||||
// 签名鉴权 + Range 透传(播放器拖进度靠它)。
|
||||
func TestReaderLocalAudioStream(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
router.HEAD("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
|
||||
payload := []byte("0123456789abcdefghij")
|
||||
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||
|
||||
// 全量拉取
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != string(payload) {
|
||||
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// Range 请求要能拖进度
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "2345" {
|
||||
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 篡改签名应被拒绝
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||
}
|
||||
|
||||
// 文件不存在 → 404(而不是 500)
|
||||
missing := container.Reader.LocalAudioURL("book-1", filepath.Join(t.TempDir(), "nope.mp3"))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, missing, nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("缺失文件 status=%d,期望 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioTranscodeRoute 转码端点:签名鉴权,命中缓存后按 Range 下发。
|
||||
// 用预置的缓存文件代替真实转码,避免测试依赖 ffmpeg。
|
||||
func TestReaderAudioTranscodeRoute(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
container.Cfg.App.DataDir = t.TempDir() // 转码缓存落在临时目录,别写进仓库
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
router.HEAD("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
|
||||
const bookID = "book-1"
|
||||
source := `D:\media\斗破苍穹\001.wma`
|
||||
payload := []byte("ID3FAKEMP3PAYLOAD0123456789")
|
||||
|
||||
cachePath, err := container.Reader.AudioTranscodeCachePath(bookID, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cachePath, payload, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signed := container.Reader.AudioTranscodeURL(bookID, source)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != string(payload) {
|
||||
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 拖进度靠 Range
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||
req.Header.Set("Range", "bytes=3-7")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "FAKEM" {
|
||||
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 篡改签名应被拒绝
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||
}
|
||||
|
||||
// 换一本书(签名不匹配)同样拒绝
|
||||
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("换书后 status=%d,期望 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioTranscodeRouteWithoutFFmpeg 没装 ffmpeg 且无缓存时返回明确错误,
|
||||
// 而不是空响应(前端据此提示用户去装 ffmpeg)。
|
||||
func TestReaderAudioTranscodeRouteWithoutFFmpeg(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
container.Cfg.App.DataDir = t.TempDir()
|
||||
container.Cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
|
||||
signed := container.Reader.AudioTranscodeURL("book-1", `D:\media\001.wma`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d,期望 502", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "ffmpeg") {
|
||||
t.Fatalf("错误正文应提到 ffmpeg: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderLocalAudioRejectsOtherBook 换一本书的签名不通用:防止拿到别人的音频地址。
|
||||
func TestReaderLocalAudioRejectsOtherBook(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
|
||||
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||
if forged == signed {
|
||||
t.Fatal("测试用例未改写 book 参数")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("换书后签名应失效,status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package handler
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -31,6 +32,13 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
|
||||
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
|
||||
|
||||
// 书源 JS 的宿主浏览器(java.startBrowser / startBrowserAwait):
|
||||
// 前端轮询待办 → 在 iframe 里承载页面 → 用户点 √ 回传 DOM。
|
||||
g.GET("/browser/pending", readerBrowserPendingHandler(svc))
|
||||
g.POST("/browser/result", readerBrowserResultHandler(svc))
|
||||
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
||||
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
||||
|
||||
// 搜索(多源聚合)
|
||||
g.POST("/search", readerSearchHandler(svc))
|
||||
|
||||
@@ -42,8 +50,14 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
// 书架
|
||||
g.GET("/books", readerListBooksHandler(svc))
|
||||
g.POST("/books", readerAddBookHandler(svc))
|
||||
// 本地书籍(TXT / EPUB 上传导入)
|
||||
g.POST("/local/books", readerImportLocalBookHandler(svc))
|
||||
// 服务器已有文件/目录导入:原地引用不复制,仅管理员(会读取允许根目录内的文件)
|
||||
g.POST("/local/books/from-path", middleware.AdminRequired(), readerImportLocalBookFromPathHandler(svc))
|
||||
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
|
||||
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
|
||||
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
|
||||
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
|
||||
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
|
||||
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
|
||||
g.GET("/books/:id/content", readerBookContentHandler(svc))
|
||||
@@ -131,7 +145,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), c.Param("id"))
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), userID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -151,7 +166,8 @@ func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := svc.Reader.RunLoginAction(c.Request.Context(), c.Param("id"), body.Action, body.Fields)
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
res, err := svc.Reader.RunLoginAction(c.Request.Context(), userID, c.Param("id"), body.Action, body.Fields)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -160,6 +176,103 @@ func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPendingHandler 前端轮询:该用户在某书源下待用户完成的页面。
|
||||
// 书源的 java.startBrowserAwait 会阻塞在服务端,前端据此把页面呈现出来。
|
||||
func readerBrowserPendingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
pages := svc.Reader.PendingBrowserPages(userID, c.Query("source_id"))
|
||||
c.JSON(http.StatusOK, gin.H{"pages": pages})
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserResultHandler 用户完成页面后回传 DOM(或取消),解除服务端阻塞。
|
||||
func readerBrowserResultHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.ResolveBrowser(body.ID, userID, body.Body, body.URL, body.Cancelled); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserXHRHandler 转发承载页面内的接口请求。
|
||||
//
|
||||
// 页面在 iframe 里是不透明源,自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦,
|
||||
// 所以由父窗口(持 JWT)把请求转交到这里,服务端补上书源凭据再发。
|
||||
func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := svc.Reader.ProxyBrowserXHR(
|
||||
c.Request.Context(), body.ID, body.Method, body.URL, body.Headers, body.Body)
|
||||
if err != nil {
|
||||
// 交给页面自己处理失败,别把 4xx 泄成框架错误
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPageHandler 承载待办页面本体。//
|
||||
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
||||
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
||||
func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap, err := svc.Reader.VerifyBrowserPage(c.Query("id"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(snap.HTML))
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserAssetHandler 页面资源/表单/站内链接的同源代理。
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(contentType), "text/css") {
|
||||
data = []byte(svc.Reader.RewriteBrowserCSS(string(data), target, c.Query("id")))
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(status, contentType, data)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
|
||||
@@ -238,9 +351,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func readerTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
chapters, err := svc.Reader.GetToc(
|
||||
c.Request.Context(),
|
||||
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
|
||||
userID, c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -294,6 +408,96 @@ func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 后台补目录缓存,让书架能显示未读章数;失败不影响加入书架本身。
|
||||
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
|
||||
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
|
||||
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 先卡住请求体大小,避免超大文件把内存打满(多给 1MB 放 multipart 头)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(reader.LocalBookMaxBytes)+(1<<20))
|
||||
header, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少上传文件(表单字段 file),或文件超过大小上限"})
|
||||
return
|
||||
}
|
||||
f, err := header.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, int64(reader.LocalBookMaxBytes)+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(data) > reader.LocalBookMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件超过大小上限"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalBook(c.Request.Context(), userID, header.Filename, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalBookFromPathHandler 从服务器已有文件导入书籍(TXT / EPUB),原地引用。
|
||||
// 仅管理员:会读取服务器上允许根目录内的任意文件。
|
||||
func readerImportLocalBookFromPathHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
abs, err := svc.FileManager.ResolvePath(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalBookFromPath(c.Request.Context(), userID, abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalAudioDirHandler 把一个服务器目录导入为一本有声书,原地引用。
|
||||
// 仅管理员;目录下的音频文件与 .strm 播放指针按相对路径排序成为章节。
|
||||
func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
abs, err := svc.FileManager.ResolvePath(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalAudioDir(c.Request.Context(), userID, abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +533,27 @@ func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerSaveAudioConfigHandler 保存听书片头/片尾跳过秒数(0 为不跳过)。
|
||||
func readerSaveAudioConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
OpenCredits int `json:"open_credits"`
|
||||
CloseCredits int `json:"close_credits"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveAudioConfig(
|
||||
c.Request.Context(), userID, c.Param("id"), body.OpenCredits, body.CloseCredits); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerListChaptersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
chapters, err := svc.Reader.ListChapters(c.Request.Context(), c.Param("id"))
|
||||
@@ -460,6 +685,77 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
|
||||
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
|
||||
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
entry, err := svc.Reader.VerifyLocalAssetURL(bookID, c.Query("p"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data, contentType, err := svc.Reader.ReadLocalAsset(c.Request.Context(), bookID, entry)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 同一本书的图片不会变,可长缓存
|
||||
c.Header("Cache-Control", "private, max-age=604800")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// readerAudioTranscodeHandler 需要转码的有声书音轨:签名鉴权(<audio src> 带不上 JWT),
|
||||
// 首次请求跑 ffmpeg 转成 mp3 落缓存,之后按 Range 下发,播放器可以拖动进度。
|
||||
func readerAudioTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
source, err := svc.Reader.VerifyAudioTranscodeURL(bookID, c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
path, err := svc.Reader.EnsureTranscodedAudio(c.Request.Context(), bookID, source)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path) // #nosec G304 -- 路径由签名校验 + 缓存目录哈希生成
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "转码结果已丢失")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "转码结果不可用")
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, max-age=604800")
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
|
||||
}
|
||||
}
|
||||
|
||||
// readerLocalAudioHandler 本地有声书音频流:签名鉴权(<audio src> 带不上 JWT),
|
||||
// 交给 http.ServeContent 处理 Range,播放器才能拖动进度。
|
||||
func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path, err := svc.Reader.VerifyLocalAudioURL(c.Query("b"), c.Query("p"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
f, info, err := svc.Reader.OpenLocalAudio(path)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
||||
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
+63
-1
@@ -3,14 +3,66 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
|
||||
// DecompressBody 兜底解压响应体(gzip / deflate)。
|
||||
//
|
||||
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
|
||||
// 并透明解压。但书源 JSON 的 header 字段、或某些 CDN 的固定策略,都可能让响应
|
||||
// 带着 Content-Encoding 回来,压缩字节一旦进入规则层,书源的 JSON.parse 就会
|
||||
// 报 `invalid character '\x1f'`。这里做一次兜底,保证调用方拿到明文。
|
||||
//
|
||||
// brotli(br)无法在纯 Go 标准库里解,因此上面不再 advertise br;真遇到 br
|
||||
// 响应则原样返回,由调用方按失败处理。
|
||||
func DecompressBody(resp *http.Response, data []byte) []byte {
|
||||
if resp == nil || len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) {
|
||||
case "gzip", "x-gzip":
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
defer r.Close()
|
||||
if out, err := io.ReadAll(r); err == nil {
|
||||
return out
|
||||
}
|
||||
case "deflate":
|
||||
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
|
||||
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil {
|
||||
return out
|
||||
}
|
||||
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
|
||||
defer zr.Close()
|
||||
if out, err := io.ReadAll(zr); err == nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
|
||||
//
|
||||
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
|
||||
// header 字段)带来的 Accept-Encoding 都必须清掉。
|
||||
func StripAcceptEncoding(h http.Header) {
|
||||
h.Del("Accept-Encoding")
|
||||
}
|
||||
|
||||
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
|
||||
// - timeout (seconds, defaults to 15)
|
||||
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
|
||||
@@ -41,12 +93,22 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
|
||||
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
|
||||
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
||||
//
|
||||
// 注意:这里刻意不设置 Accept-Encoding。
|
||||
//
|
||||
// net/http 只在「调用方没有显式设置 Accept-Encoding」时才会自己补上 gzip 并
|
||||
// 透明解压;一旦我们显式写了这个头,它就原样把压缩字节交出来。之前这里写了
|
||||
// "gzip, deflate, br",于是所有经历了压缩的响应(静态 JSON、CDN 上的页面等)
|
||||
// 都会以原始压缩字节进入规则层,书源的 JSON.parse 直接报
|
||||
// `SyntaxError: invalid character '\x1f'`,表现为「获取最新配置失败」。
|
||||
//
|
||||
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
|
||||
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
|
||||
func HTTPHeaderPresets() map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:压缩相关的回归测试。
|
||||
//
|
||||
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
|
||||
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
|
||||
|
||||
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
|
||||
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
|
||||
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
|
||||
for k := range HTTPHeaderPresets() {
|
||||
if http.CanonicalHeaderKey(k) == "Accept-Encoding" {
|
||||
t.Fatal("HTTPHeaderPresets 不应设置 Accept-Encoding(会让 net/http 放弃自动解压)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripAcceptEncoding 任何来源带来的 Accept-Encoding 都应被清掉。
|
||||
func TestStripAcceptEncoding(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Accept-Encoding", "br, gzip")
|
||||
StripAcceptEncoding(h)
|
||||
if got := h.Get("Accept-Encoding"); got != "" {
|
||||
t.Fatalf("Accept-Encoding 未清理: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecompressBody gzip / deflate 响应体应被还原成明文。
|
||||
func TestDecompressBody(t *testing.T) {
|
||||
payload := `{"version":"20260926"}`
|
||||
|
||||
t.Run("gzip", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, _ = gz.Write([]byte(payload))
|
||||
_ = gz.Close()
|
||||
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
resp.Header.Set("Content-Encoding", "gzip")
|
||||
if got := string(DecompressBody(resp, buf.Bytes())); got != payload {
|
||||
t.Fatalf("gzip 解压 = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未压缩原样返回", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
if got := string(DecompressBody(resp, []byte(payload))); got != payload {
|
||||
t.Fatalf("未压缩响应被改动: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("坏数据不 panic", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
resp.Header.Set("Content-Encoding", "gzip")
|
||||
raw := []byte("not-gzip")
|
||||
if got := string(DecompressBody(resp, raw)); got != "not-gzip" {
|
||||
t.Fatalf("解压失败时应原样返回,实际 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil resp", func(t *testing.T) {
|
||||
if got := string(DecompressBody(nil, []byte(payload))); got != payload {
|
||||
t.Fatalf("nil resp 应原样返回,实际 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,6 +65,19 @@ type ReaderBook struct {
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
|
||||
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
|
||||
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
|
||||
LocalPath string `gorm:"type:varchar(255)" json:"-"`
|
||||
// LocalExternal 为真表示原地引用服务器上已有的文件/目录(管理员在导入时选定),
|
||||
// 移出书架只解除引用,不删除源文件;为假表示 data/reader/local 下的托管副本。
|
||||
LocalExternal bool `json:"local_external"`
|
||||
// IsLocal 是否本地导入书籍,列表接口按需计算,不落库。
|
||||
IsLocal bool `gorm:"-" json:"is_local"`
|
||||
// 听书(音频源)跳过片头/片尾秒数,对应 legado Book.getOpenCredits/getCloseCredits。
|
||||
// 0 表示不跳过。仅对音频和视频源生效。
|
||||
OpenCredits int `gorm:"default:0" json:"open_credits"`
|
||||
CloseCredits int `gorm:"default:0" json:"close_credits"`
|
||||
}
|
||||
|
||||
// ReaderChapter 章节缓存(对应 legado BookChapter)。
|
||||
|
||||
@@ -139,7 +139,8 @@ func (r *ReaderRepository) DeleteBook(ctx context.Context, userID, id string) er
|
||||
// ReplaceChapters 覆盖式刷新章节列表。
|
||||
func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, chapters []model.ReaderChapter) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
|
||||
// 章节是纯缓存(软删会留下行,撞上 (book_id, index) 唯一索引),这里物理删除
|
||||
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
@@ -156,6 +157,32 @@ func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]m
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
|
||||
// 没有目录缓存的书籍不会出现在返回结果里。
|
||||
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(bookIDs))
|
||||
if len(bookIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []struct {
|
||||
BookID string
|
||||
Total int
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.ReaderChapter{}).
|
||||
Select("book_id, COUNT(*) AS total").
|
||||
Where("book_id IN ?", bookIDs).
|
||||
Group("book_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.BookID] = row.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetChapter 取指定章节。
|
||||
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
|
||||
var out model.ReaderChapter
|
||||
|
||||
@@ -66,6 +66,14 @@ var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
|
||||
// ErrRootMutation protects configured roots such as /media and /downloads.
|
||||
var ErrRootMutation = errors.New("refusing to mutate an allowed root")
|
||||
|
||||
// ResolvePath validates that path sits inside one of the allowed storage roots
|
||||
// and returns its absolute form. Callers use it to accept a server-side path
|
||||
// from the UI (file pickers) without re-implementing the boundary check.
|
||||
func (s *FileManagerService) ResolvePath(path string) (string, error) {
|
||||
abs, _, err := s.requireAllowedPath(path, false)
|
||||
return abs, err
|
||||
}
|
||||
|
||||
// List enumerates a directory under one of the allowed roots, returning up to
|
||||
// maxEntries items sorted by (dir-first, path). Recursive listing is capped by
|
||||
// maxEntries to avoid accidentally walking huge NAS trees from the UI.
|
||||
|
||||
@@ -153,6 +153,37 @@ func TestFileManagerRefusesRootMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileManagerResolvePath 阅读模块的服务器选书/选目录入口靠它做边界校验:
|
||||
// 允许根目录内放行,越界一律拒绝。
|
||||
func TestFileManagerResolvePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
svc := newFileManagerTestService(t, root)
|
||||
|
||||
inside := filepath.Join(root, "books", "小说.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(inside, []byte("正文"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.ResolvePath(inside)
|
||||
if err != nil {
|
||||
t.Fatalf("允许根目录内的路径应放行: %v", err)
|
||||
}
|
||||
if got != inside {
|
||||
t.Fatalf("ResolvePath = %q,期望 %q", got, inside)
|
||||
}
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "越界.txt")
|
||||
if err := os.WriteFile(outside, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ResolvePath(outside); !errors.Is(err, ErrPathOutOfBounds) {
|
||||
t.Fatalf("越界路径 err = %v, want ErrPathOutOfBounds", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hardlinksUnsupported(t *testing.T, root string) bool {
|
||||
t.Helper()
|
||||
src := filepath.Join(root, "hardlink-probe-src")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestSaveAudioConfigPersistsCredits 听书跳过片头/片尾设置要落库,且只能改自己的书。
|
||||
func TestSaveAudioConfigPersistsCredits(t *testing.T) {
|
||||
svc, repos := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book := &model.ReaderBook{UserID: "u1", Name: "宠魅", Type: 1}
|
||||
if err := repos.Reader.CreateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 30, 15); err != nil {
|
||||
t.Fatalf("保存听书设置失败: %v", err)
|
||||
}
|
||||
got, err := svc.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OpenCredits != 30 || got.CloseCredits != 15 {
|
||||
t.Fatalf("片头/片尾 = %d/%d,期望 30/15", got.OpenCredits, got.CloseCredits)
|
||||
}
|
||||
|
||||
// 0 是合法值(不跳过),必须能写回
|
||||
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 0, 0); err != nil {
|
||||
t.Fatalf("清零失败: %v", err)
|
||||
}
|
||||
if got, _ = svc.GetBook(ctx, book.ID); got.OpenCredits != 0 || got.CloseCredits != 0 {
|
||||
t.Fatalf("清零后 = %d/%d,期望 0/0", got.OpenCredits, got.CloseCredits)
|
||||
}
|
||||
|
||||
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, -1, 0); err == nil {
|
||||
t.Fatal("负数应被拒绝")
|
||||
}
|
||||
if err := svc.SaveAudioConfig(ctx, "other", book.ID, 10, 10); err == nil {
|
||||
t.Fatal("他人书架应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// 本地/远端有声书的按需转码:源文件是浏览器解不了的格式(典型是 WMA/ASF)时,
|
||||
// 用 ffmpeg 转成 mp3 落到缓存目录,再按 Range 下发。
|
||||
//
|
||||
// 为什么落盘而不是实时流式输出:有声书必须能拖动进度,而 http.ServeContent 的
|
||||
// Range 支持要求可随机读取的文件;实时管道没有长度信息,一旦 seek 就废掉。
|
||||
// 一章通常几 MB,转一次几秒,之后同一章秒开。
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
// audioTranscodeFlight 同一章节并发命中时只跑一次 ffmpeg(按缓存路径去重)。
|
||||
var audioTranscodeFlight singleflight.Group
|
||||
|
||||
// 转码互斥表:singleflight 只保证同一 key 不重复执行,prune 与写文件仍需串行。
|
||||
var audioTranscodeMu sync.Mutex
|
||||
|
||||
const (
|
||||
// audioTranscodeDirName 缓存目录名,位于 cache.cache_dir 之下。
|
||||
audioTranscodeDirName = "reader-audio"
|
||||
// maxAudioTranscodeCacheBytes 转码缓存上限,超出按修改时间淘汰最旧的。
|
||||
maxAudioTranscodeCacheBytes = 4 << 30
|
||||
// audioTranscodeKeepRatio 触发淘汰后回落到上限的比例,避免每次写入都淘汰。
|
||||
audioTranscodeKeepRatio = 0.9
|
||||
// maxAudioTranscodeDuration 单章转码超时。
|
||||
maxAudioTranscodeDuration = 15 * time.Minute
|
||||
// audioTranscodeBitrate 语音内容 96k 足够,体积约为原 WMA 的两倍以内。
|
||||
audioTranscodeBitrate = "96k"
|
||||
)
|
||||
|
||||
// needsTranscodeAudioExt 明确需要转码的容器/编码(浏览器都无法直接解码)。
|
||||
// 只列已知有问题的:未列出的格式维持原样直出,避免把本来能播的流也拖去转码。
|
||||
var needsTranscodeAudioExt = map[string]bool{
|
||||
".wma": true, ".asf": true, ".wmv": true, ".ape": true, ".wv": true,
|
||||
".ac3": true, ".dts": true, ".amr": true, ".tta": true, ".dsf": true, ".dff": true,
|
||||
}
|
||||
|
||||
// audioSourceExt 取音频地址的扩展名(小写带点)。
|
||||
//
|
||||
// 既要认本地路径(D:\x\a.wma),也要认带 query 的远端地址
|
||||
// (…/video.wma?acct=…)——后者直接 filepath.Ext 会把 query 一起算进去。
|
||||
func audioSourceExt(source string) string {
|
||||
raw := strings.TrimSpace(source)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// scheme 长度 >1 才算真 URL:Windows 盘符(D:\…)会被 url.Parse 当成单字符 scheme
|
||||
if u, err := url.Parse(raw); err == nil && len(u.Scheme) > 1 {
|
||||
return strings.ToLower(filepath.Ext(u.Path))
|
||||
}
|
||||
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(raw))
|
||||
}
|
||||
|
||||
// needsAudioTranscode 判断该音频是否必须转码后才能给浏览器播放。
|
||||
func needsAudioTranscode(source string) bool {
|
||||
return needsTranscodeAudioExt[audioSourceExt(source)]
|
||||
}
|
||||
|
||||
// ffmpegBinary 解析可用的 ffmpeg 可执行文件;找不到返回空串。
|
||||
func (s *ReaderService) ffmpegBinary() string {
|
||||
path := strings.TrimSpace(s.cfg.App.FFmpegPath)
|
||||
if path == "" {
|
||||
path = "ffmpeg"
|
||||
}
|
||||
resolved, err := exec.LookPath(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// ErrAudioTranscodeUnavailable 服务器没有 ffmpeg,无法转码该格式。
|
||||
var ErrAudioTranscodeUnavailable = errors.New("服务器未安装 ffmpeg")
|
||||
|
||||
// transcodeMissingFFmpegError 给前端一条能直接照做的提示。
|
||||
func transcodeMissingFFmpegError(source string) error {
|
||||
ext := strings.TrimPrefix(audioSourceExt(source), ".")
|
||||
if ext == "" {
|
||||
ext = "该"
|
||||
}
|
||||
return fmt.Errorf("这个音频是 %s 格式,浏览器无法直接播放,需要服务器转码;%w,请在设置里配置 app.ffmpeg_path 或安装 ffmpeg",
|
||||
strings.ToUpper(ext), ErrAudioTranscodeUnavailable)
|
||||
}
|
||||
|
||||
// ─── 签名(与本地音频同一套:<audio src> 带不上 JWT) ─────────────────────
|
||||
|
||||
func (s *ReaderService) signAudioTranscode(bookID, source string) string {
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
|
||||
mac.Write([]byte(bookID + "|transcode|" + source))
|
||||
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
// AudioTranscodeURL 需要转码的音频地址(签名代理)。
|
||||
func (s *ReaderService) AudioTranscodeURL(bookID, source string) string {
|
||||
return "/api/reader/audio/transcode?b=" + url.QueryEscape(bookID) +
|
||||
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(source)) +
|
||||
"&s=" + s.signAudioTranscode(bookID, source)
|
||||
}
|
||||
|
||||
// VerifyAudioTranscodeURL 校验签名并还原原始音频地址(本地路径或远端 URL)。
|
||||
func (s *ReaderService) VerifyAudioTranscodeURL(bookID, encoded, sig string) (string, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("转码地址解码失败")
|
||||
}
|
||||
expect := s.signAudioTranscode(bookID, string(raw))
|
||||
if !hmac.Equal([]byte(expect), []byte(sig)) {
|
||||
return "", fmt.Errorf("转码签名校验失败")
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// ─── 缓存与转码 ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *ReaderService) audioTranscodeDir() (string, error) {
|
||||
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
|
||||
if base == "" {
|
||||
base = filepath.Join(s.cfg.App.DataDir, "cache")
|
||||
}
|
||||
dir := filepath.Join(base, audioTranscodeDirName)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", fmt.Errorf("创建转码缓存目录失败: %w", err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// audioTranscodeCachePath 缓存文件名由(书 + 源地址)哈希决定:同一章重复播放直接命中。
|
||||
func audioTranscodeCachePath(dir, bookID, source string) string {
|
||||
sum := sha256.Sum256([]byte(bookID + "|" + source))
|
||||
return filepath.Join(dir, hex.EncodeToString(sum[:16])+".mp3")
|
||||
}
|
||||
|
||||
// AudioTranscodeCachePath 返回该音轨的转码缓存文件路径(不触发转码),
|
||||
// 供测试与运维排查使用。
|
||||
func (s *ReaderService) AudioTranscodeCachePath(bookID, source string) (string, error) {
|
||||
dir, err := s.audioTranscodeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return audioTranscodeCachePath(dir, bookID, source), nil
|
||||
}
|
||||
|
||||
// EnsureTranscodedAudio 确保该音频已有转码结果,返回可 Range 下发的 mp3 路径。
|
||||
func (s *ReaderService) EnsureTranscodedAudio(ctx context.Context, bookID, source string) (string, error) {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return "", fmt.Errorf("缺少音频地址")
|
||||
}
|
||||
dir, err := s.audioTranscodeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := audioTranscodeCachePath(dir, bookID, source)
|
||||
if ok := touchCachedAudio(dst); ok {
|
||||
return dst, nil
|
||||
}
|
||||
if s.ffmpegBinary() == "" {
|
||||
return "", transcodeMissingFFmpegError(source)
|
||||
}
|
||||
|
||||
v, err, _ := audioTranscodeFlight.Do(dst, func() (any, error) {
|
||||
// 排队期间别的请求可能已经转好了
|
||||
if ok := touchCachedAudio(dst); ok {
|
||||
return dst, nil
|
||||
}
|
||||
if err := s.transcodeAudioFile(ctx, bookID, source, dst, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, _ := v.(string)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("转码结果不可用")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// touchCachedAudio 命中缓存时刷新访问时间,作为 LRU 依据。
|
||||
func touchCachedAudio(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() || info.Size() == 0 {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(path, now, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// transcodeAudioFile 跑一次 ffmpeg 并原子落盘(先写 .part 再 rename)。
|
||||
func (s *ReaderService) transcodeAudioFile(ctx context.Context, bookID, source, dst, dir string) error {
|
||||
ffmpeg := s.ffmpegBinary()
|
||||
if ffmpeg == "" {
|
||||
return transcodeMissingFFmpegError(source)
|
||||
}
|
||||
remote := isRemoteMediaURL(source)
|
||||
if !remote {
|
||||
if info, err := os.Stat(source); err != nil || info.IsDir() {
|
||||
return fmt.Errorf("音频文件已丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// 转码与淘汰串行,避免边写边删
|
||||
audioTranscodeMu.Lock()
|
||||
pruneAudioTranscodeCache(dir, maxAudioTranscodeCacheBytes)
|
||||
audioTranscodeMu.Unlock()
|
||||
|
||||
tmp := dst + ".part"
|
||||
defer os.Remove(tmp)
|
||||
|
||||
headers := s.transcodeInputHeaders(ctx, bookID, source)
|
||||
// 客户端断开不该杀掉已开始的转码:产物对下次播放仍然有用
|
||||
runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), maxAudioTranscodeDuration)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, ffmpeg, buildFFmpegAudioArgs(source, tmp, headers)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("转码超时(超过 %s)", maxAudioTranscodeDuration)
|
||||
}
|
||||
return fmt.Errorf("转码失败: %v: %s", err, truncateForError(string(out), 300))
|
||||
}
|
||||
info, err := os.Stat(tmp)
|
||||
if err != nil || info.Size() == 0 {
|
||||
return fmt.Errorf("转码没有产生有效输出")
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return fmt.Errorf("写入转码缓存失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transcodeInputHeaders 远端音源要带的请求头:浏览器 UA 预设 + 书源请求头 + 登录态。
|
||||
// 本机 /api/strm 这类内网地址不需要,但书源直链类的有声书要靠它才能取到。
|
||||
func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, source string) map[string]string {
|
||||
if !isRemoteMediaURL(source) || s.repo == nil {
|
||||
return nil
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
headers[k] = v
|
||||
}
|
||||
book, err := s.repo.GetBook(ctx, bookID)
|
||||
if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" {
|
||||
return headers
|
||||
}
|
||||
if src, err := s.repo.GetSourceByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
|
||||
var extra map[string]any
|
||||
if json.Unmarshal([]byte(src.Header), &extra) == nil {
|
||||
for k, v := range extra {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
state := s.newSourceState(ctx, book.Origin)
|
||||
for k, v := range state.LoginHeaderMap() {
|
||||
if !strings.EqualFold(k, "cookie") {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
if ck := state.CookieForRequest(source); ck != "" {
|
||||
headers["Cookie"] = ck
|
||||
}
|
||||
if headers["Referer"] == "" {
|
||||
headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/"
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// buildFFmpegAudioArgs 组装音频转码参数(纯函数,便于测试)。
|
||||
// 只取第一条音频流:WMA 常把专辑封面挂在视频流上,-vn 一并丢掉。
|
||||
func buildFFmpegAudioArgs(source, output string, headers map[string]string) []string {
|
||||
args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y"}
|
||||
if len(headers) > 0 {
|
||||
keys := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(headers[k])
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
args = append(args, "-headers", b.String())
|
||||
}
|
||||
return append(args,
|
||||
"-i", source,
|
||||
"-vn", "-map", "0:a:0",
|
||||
"-c:a", "libmp3lame", "-b:a", audioTranscodeBitrate,
|
||||
"-f", "mp3", output,
|
||||
)
|
||||
}
|
||||
|
||||
// pruneAudioTranscodeCache 缓存超过上限时按访问时间淘汰,回落到上限的 90%。
|
||||
func pruneAudioTranscodeCache(dir string, maxBytes int64) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type cacheItem struct {
|
||||
path string
|
||||
size int64
|
||||
mod time.Time
|
||||
}
|
||||
items := make([]cacheItem, 0, len(entries))
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".mp3") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, cacheItem{filepath.Join(dir, e.Name()), info.Size(), info.ModTime()})
|
||||
total += info.Size()
|
||||
}
|
||||
if total <= maxBytes {
|
||||
return
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].mod.Before(items[j].mod) })
|
||||
target := int64(float64(maxBytes) * audioTranscodeKeepRatio)
|
||||
for _, it := range items {
|
||||
if total <= target {
|
||||
break
|
||||
}
|
||||
if os.Remove(it.path) == nil {
|
||||
total -= it.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForError 截断 ffmpeg 输出,避免把整段 stderr 塞进响应。
|
||||
func truncateForError(s string, limit int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= limit {
|
||||
return s
|
||||
}
|
||||
return s[:limit] + "…"
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestAudioSourceExt 音频地址取扩展名:本地路径、带 query 的远端地址、
|
||||
// Windows 盘符(会被 url.Parse 当成单字符 scheme)都要认对。
|
||||
func TestAudioSourceExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{`D:\media\斗破苍穹\001-250\001.wma`, ".wma"},
|
||||
{"/media/books/01.WMA", ".wma"},
|
||||
{"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x&path=y", ".wma"},
|
||||
{"https://cdn.example.com/a/b.mp3?token=1#frag", ".mp3"},
|
||||
{"https://cdn.example.com/stream", ""},
|
||||
{"/media/books/01.m4a", ".m4a"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := audioSourceExt(tc.in); got != tc.want {
|
||||
t.Errorf("audioSourceExt(%q) = %q,期望 %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsAudioTranscode 只对浏览器确定解不了的格式转码,其余维持直出。
|
||||
func TestNeedsAudioTranscode(t *testing.T) {
|
||||
need := []string{
|
||||
`D:\media\001.wma`,
|
||||
"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x",
|
||||
"/media/01.asf",
|
||||
"/media/01.ape",
|
||||
}
|
||||
for _, s := range need {
|
||||
if !needsAudioTranscode(s) {
|
||||
t.Errorf("%q 应需要转码", s)
|
||||
}
|
||||
}
|
||||
direct := []string{
|
||||
"/media/01.mp3", "/media/01.m4a", "/media/01.m4b", "/media/01.flac",
|
||||
"/media/01.ogg", "/media/01.opus", "/media/01.wav",
|
||||
"https://cdn.example.com/stream", "",
|
||||
}
|
||||
for _, s := range direct {
|
||||
if needsAudioTranscode(s) {
|
||||
t.Errorf("%q 不该被转码", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioTranscodeURLSignature 转码地址的签名可校验、改一处即失效。
|
||||
func TestAudioTranscodeURLSignature(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
source := `D:\media\斗破苍穹\001-250\001.wma`
|
||||
|
||||
raw := svc.AudioTranscodeURL("book-1", source)
|
||||
if !strings.HasPrefix(raw, "/api/reader/audio/transcode?b=book-1&u=") {
|
||||
t.Fatalf("转码地址格式不对: %q", raw)
|
||||
}
|
||||
encoded, sig := queryParam(t, raw, "u"), queryParam(t, raw, "s")
|
||||
got, err := svc.VerifyAudioTranscodeURL("book-1", encoded, sig)
|
||||
if err != nil || got != source {
|
||||
t.Fatalf("签名校验失败: %v / %q", err, got)
|
||||
}
|
||||
if _, err := svc.VerifyAudioTranscodeURL("book-1", encoded, "deadbeef"); err == nil {
|
||||
t.Fatal("错误签名应校验失败")
|
||||
}
|
||||
if _, err := svc.VerifyAudioTranscodeURL("book-2", encoded, sig); err == nil {
|
||||
t.Fatal("换一本书后签名应失效")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioTranscodeCachePath 缓存名对(书 + 源地址)稳定且互不冲突。
|
||||
func TestAudioTranscodeCachePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
||||
b := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
||||
if a != b {
|
||||
t.Fatalf("同一输入应得到同一缓存路径: %q vs %q", a, b)
|
||||
}
|
||||
if !strings.HasSuffix(a, ".mp3") {
|
||||
t.Fatalf("缓存文件应为 mp3: %q", a)
|
||||
}
|
||||
for _, other := range []string{
|
||||
audioTranscodeCachePath(dir, "book-2", "/x/001.wma"),
|
||||
audioTranscodeCachePath(dir, "book-1", "/x/002.wma"),
|
||||
} {
|
||||
if other == a {
|
||||
t.Fatalf("不同书/不同章节不该共用缓存: %q", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureTranscodedAudioWithoutFFmpeg 没装 ffmpeg 时要给出可照做的错误。
|
||||
func TestEnsureTranscodedAudioWithoutFFmpeg(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||
|
||||
_, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", `D:\media\001.wma`)
|
||||
if !errors.Is(err, ErrAudioTranscodeUnavailable) {
|
||||
t.Fatalf("err = %v,应包含 ErrAudioTranscodeUnavailable", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "WMA") || !strings.Contains(err.Error(), "ffmpeg") {
|
||||
t.Fatalf("错误信息应说明格式与解决办法,实际 %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureTranscodedAudioUsesCache 已有转码结果时直接命中缓存,不依赖 ffmpeg。
|
||||
func TestEnsureTranscodedAudioUsesCache(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||
|
||||
source := `D:\media\001.wma`
|
||||
dir, err := svc.audioTranscodeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := audioTranscodeCachePath(dir, "book-1", source)
|
||||
if err := os.WriteFile(want, []byte("MP3DATA"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", source)
|
||||
if err != nil {
|
||||
t.Fatalf("命中缓存时不该报错: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("缓存路径 = %q,期望 %q", got, want)
|
||||
}
|
||||
data, err := os.ReadFile(got)
|
||||
if err != nil || string(data) != "MP3DATA" {
|
||||
t.Fatalf("缓存内容被改动了: %q / %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFFmpegAudioArgs ffmpeg 参数:输入输出位置正确,丢掉视频流,
|
||||
// 强制 mp3 编码,请求头按 key 排序保证可复现。
|
||||
func TestBuildFFmpegAudioArgs(t *testing.T) {
|
||||
args := buildFFmpegAudioArgs("/in/01.wma", "/out/01.mp3", nil)
|
||||
if args[0] != "-hide_banner" || args[len(args)-1] != "/out/01.mp3" {
|
||||
t.Fatalf("参数首尾不对: %v", args)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, want := range []string{"-i /in/01.wma", "-vn", "-map 0:a:0", "-c:a libmp3lame", "-f mp3"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("参数缺少 %q: %v", want, args)
|
||||
}
|
||||
}
|
||||
|
||||
withHeaders := buildFFmpegAudioArgs("u", "o", map[string]string{
|
||||
"User-Agent": "mebox", "Cookie": "a=1",
|
||||
})
|
||||
idx := indexOf(withHeaders, "-headers")
|
||||
if idx < 0 {
|
||||
t.Fatalf("应带上 -headers: %v", withHeaders)
|
||||
}
|
||||
headers := withHeaders[idx+1]
|
||||
if !strings.HasPrefix(headers, "Cookie: a=1\r\n") || !strings.Contains(headers, "User-Agent: mebox\r\n") {
|
||||
t.Fatalf("请求头内容或顺序不对: %q", headers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneAudioTranscodeCache 超过上限时按访问时间淘汰最旧的,落到 90%。
|
||||
func TestPruneAudioTranscodeCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := time.Now().Add(-time.Hour)
|
||||
sizes := []int64{400, 400, 400}
|
||||
names := []string{"old.mp3", "mid.mp3", "new.mp3"}
|
||||
var total int64
|
||||
for i, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, sizes[i]), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := base.Add(time.Duration(i) * time.Minute)
|
||||
if err := os.Chtimes(filepath.Join(dir, name), ts, ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total += sizes[i]
|
||||
}
|
||||
// 上限设为总量的一半:必须淘汰
|
||||
pruneAudioTranscodeCache(dir, total/2)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "old.mp3")); !os.IsNotExist(err) {
|
||||
t.Errorf("最旧的缓存应被删除,stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "new.mp3")); err != nil {
|
||||
t.Errorf("最新的缓存应保留: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalAudioTrackWithoutFFmpeg localAudioTrack 在需要转码但没 ffmpeg 时,
|
||||
// 直接给出可读的错误,而不是返回一个注定播不了的地址。
|
||||
func TestLocalAudioTrackWithoutFFmpeg(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||
|
||||
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
||||
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.wma`}
|
||||
_, transcoding, err := svc.localAudioTrack(book, ch)
|
||||
if err == nil {
|
||||
t.Fatal("缺 ffmpeg 时应报错")
|
||||
}
|
||||
if transcoding {
|
||||
t.Error("报错时不该标记为转码中")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ffmpeg") {
|
||||
t.Fatalf("错误信息应提到 ffmpeg: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalAudioTrackPlayableStaysDirect 浏览器能播的格式不转码,仍走本地音频端点。
|
||||
func TestLocalAudioTrackPlayableStaysDirect(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
||||
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.mp3`}
|
||||
|
||||
track, transcoding, err := svc.localAudioTrack(book, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("mp3 不该报错: %v", err)
|
||||
}
|
||||
if transcoding {
|
||||
t.Error("mp3 不该标记为转码中")
|
||||
}
|
||||
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
|
||||
t.Fatalf("mp3 应走本地音频端点: %q", track)
|
||||
}
|
||||
}
|
||||
|
||||
// queryParam 取查询参数(签名用例共用)。
|
||||
func queryParam(t *testing.T, raw, key string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u.Query().Get(key)
|
||||
}
|
||||
|
||||
func indexOf(items []string, want string) int {
|
||||
for i, v := range items {
|
||||
if v == want {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:详情 init 链式取值 与 书籍类型翻译 的回归测试。
|
||||
//
|
||||
// 背景(光遇聚合):
|
||||
// - ruleBookInfo.init 是 `<js>…</js>$.data` 组合规则,它的结果会成为后续
|
||||
// name/author/tocUrl 的解析内容(对应 legado
|
||||
// `analyzeRule.setContent(analyzeRule.getElement(infoRule.init))`)。
|
||||
// MeBox 早期把 init 结果当成 JSON 解析 map 再按写死的键名取值,导致
|
||||
// 详情页「书名/作者为空、目录 0 章」。
|
||||
// - 书源在目录规则里用 legado 的 BookType 位掩码给 book.type 赋值
|
||||
// (8=文本 32=音频 64=图片 4=视频),而 MeBox 用 0/1/2/3,必须换算,
|
||||
// 否则听书源会被当成文本,正文渲染成一串裸 URL。
|
||||
|
||||
// TestBookInfoInitResultBecomesContent init 的结果要成为后续字段的解析内容。
|
||||
func TestBookInfoInitResultBecomesContent(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")
|
||||
switch r.URL.Path {
|
||||
case "/detail":
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"book_name":"宠魅","author":"某作者","thumb_url":"https://img.example.com/c.jpg","toc_url":"https://example.com/toc"}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`<html><body>book page</body></html>`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": srv.URL,
|
||||
"bookSourceName": "init 链式测试源",
|
||||
"ruleBookInfo": map[string]any{
|
||||
// 组合规则:先跑 JS 取详情接口,再用 $.data 取出对象
|
||||
"init": fmt.Sprintf(`<js>java.ajax(%q)</js>$.data`, srv.URL+"/detail"),
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"coverUrl": "$.thumb_url",
|
||||
"tocUrl": "$.toc_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
sourceID := prepareLoginSource(t, svc, string(out))
|
||||
|
||||
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
|
||||
if err != nil {
|
||||
t.Fatalf("取详情失败: %v", err)
|
||||
}
|
||||
if info.Name != "宠魅" {
|
||||
t.Fatalf("书名 = %q(init 结果未成为解析内容)", info.Name)
|
||||
}
|
||||
if info.Author != "某作者" {
|
||||
t.Fatalf("作者 = %q", info.Author)
|
||||
}
|
||||
if info.CoverURL != "https://img.example.com/c.jpg" {
|
||||
t.Fatalf("封面 = %q", info.CoverURL)
|
||||
}
|
||||
if info.TocURL != "https://example.com/toc" {
|
||||
t.Fatalf("目录地址 = %q", info.TocURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBookType 把 legado 的 BookType 位掩码换算成 MeBox 的 0/1/2/3。
|
||||
func TestNormalizeBookType(t *testing.T) {
|
||||
cases := map[int]int{
|
||||
0: 0, // 未知/默认 → 文本
|
||||
8: 0, // text
|
||||
32: 1, // audio(听书源默认值)
|
||||
64: 2, // image(漫画源默认值)
|
||||
4: 3, // video(短剧源默认值)
|
||||
1: 0, // 苹果端遗留值不再是音频
|
||||
2: 0,
|
||||
// 组合位:按 音频 > 图片 > 视频 > 文本 的优先级取一个
|
||||
32 | 8: 1,
|
||||
64 | 8: 2,
|
||||
4 | 8: 3,
|
||||
32 | 64: 1,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeBookType(in); got != want {
|
||||
t.Errorf("normalizeBookType(%d) = %d,期望 %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:书架未读章数所需的「网络书籍总章数从目录缓存补齐」逻辑回归测试。
|
||||
// 前端的未读章数 = total_chapter_num -(已读到第几章),所以这里保证 total_chapter_num 可靠。
|
||||
|
||||
func TestListBooksFillsChapterCountFromCache(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
OriginName: "测试书源",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "测试书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if book.TotalChapterNum != 0 {
|
||||
t.Fatalf("刚加入书架的网文总章数 = %d,期望 0", book.TotalChapterNum)
|
||||
}
|
||||
|
||||
// 目录还没缓存:书架读不到总章数,前端此时不显示未读徽标
|
||||
books, err := svc.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(books) != 1 || books[0].TotalChapterNum != 0 {
|
||||
t.Fatalf("未缓存目录时书架 = %+v,期望 1 本且总章数 0", books)
|
||||
}
|
||||
|
||||
// 缓存 5 章后,书架应能算出总章数
|
||||
chapters := make([]ChapterInput, 0, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
chapters = append(chapters, ChapterInput{
|
||||
Index: i,
|
||||
Title: fmt.Sprintf("第 %d 章", i+1),
|
||||
URL: fmt.Sprintf("https://example.com/c/%d", i),
|
||||
})
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
books, err = svc.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].TotalChapterNum != 5 {
|
||||
t.Fatalf("缓存 5 章后总章数 = %d,期望 5", books[0].TotalChapterNum)
|
||||
}
|
||||
|
||||
// 读到第 2 章后,前端用 dur_chapter_index + 1 算已读;这里确认进度按原样回读
|
||||
if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第 2 章"); err != nil {
|
||||
t.Fatalf("保存进度失败: %v", err)
|
||||
}
|
||||
books, err = svc.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterIndex != 1 || books[0].DurChapterTime == 0 {
|
||||
t.Fatalf("进度 = index %d / time %d,期望 index 1 且有时间戳", books[0].DurChapterIndex, books[0].DurChapterTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBooksChapterCountIsPerUser(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "测试书", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: "u"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 别人的书架不应该受影响,也不该拿到别人的书
|
||||
other, err := svc.ListBooks(ctx, "u2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("u2 的书架应为空,得到 %+v", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmUpBookChaptersSkipsWhenCacheExists(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "测试书", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chapters := []ChapterInput{
|
||||
{Index: 0, Title: "第 1 章", URL: "https://example.com/c/0"},
|
||||
{Index: 1, Title: "第 2 章", URL: "https://example.com/c/1"},
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 已有目录缓存时直接返回,不联网、不覆盖缓存
|
||||
svc.WarmUpBookChapters(ctx, "u1", book)
|
||||
|
||||
got, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Title != "第 1 章" {
|
||||
t.Fatalf("缓存被改动: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmUpBookChaptersSkipsLocalBook(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
// 本地书籍不应触发联网抓目录(书源为空的地址必然失败),章节保持导入时的结果
|
||||
svc.WarmUpBookChapters(ctx, "u1", book)
|
||||
|
||||
got, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("本地书籍章节数 = %d,期望 3", len(got))
|
||||
}
|
||||
if book.TotalChapterNum != 3 {
|
||||
t.Fatalf("本地书籍总章数 = %d,期望 3", book.TotalChapterNum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:书源 JS 的宿主浏览器(java.startBrowser / java.startBrowserAwait)。
|
||||
//
|
||||
// legado 用内置 WebView 承载页面:书源在自己的 JS 里拼一段 HTML(线路设置页
|
||||
// 之类)或给一个网址,让用户点选、登录、过防爬校验,然后(Await 版本)把用户
|
||||
// 操作后的页面源码当成 StrResponse 返回,书源再从中解析出结果写回源变量。
|
||||
//
|
||||
// 服务端没有 WebView,于是把这一段拆成三步:
|
||||
// 1. JS 侧 startBrowserAwait 登记一个待办并阻塞等待;
|
||||
// 2. 前端轮询到待办,在 <iframe> 里承载页面(同源,因此可以直接读回 DOM);
|
||||
// 3. 用户点 √ 后把 iframe 的 DOM 回传,阻塞解除,JS 拿到 body()。
|
||||
//
|
||||
// 页面来源分两种,承载方式不同:
|
||||
// - data:text/html;base64,…:书源自带的内嵌页面,直接解码返回;
|
||||
// - http(s):服务端带书源 Cookie/请求头抓取后返回。这一步很关键——书源的
|
||||
// 登录态存在服务端,浏览器里没有这些 Cookie,若直接让浏览器打开源站,
|
||||
// 「用户后台」这类页面必然是未登录状态。页面里的资源地址会改写到同源
|
||||
// 代理,使后续请求同样带上 Cookie。
|
||||
|
||||
const (
|
||||
// browserAwaitTimeout 单次等待用户操作的上限。
|
||||
browserAwaitTimeout = 10 * time.Minute
|
||||
// browserPageTTL 待办条目与页面链接的有效期。
|
||||
browserPageTTL = 15 * time.Minute
|
||||
// browserMaxPageBytes 服务端抓取页面/资源的大小上限。
|
||||
browserMaxPageBytes = 4 << 20
|
||||
// browserFetchTimeout 抓取待承载页面/资源的超时。
|
||||
browserFetchTimeout = 20 * time.Second
|
||||
// browserModeWait 需要回传 DOM(startBrowserAwait)。
|
||||
browserModeWait = "wait"
|
||||
// browserModeOpen 只展示(startBrowser / showBrowser)。
|
||||
browserModeOpen = "open"
|
||||
)
|
||||
|
||||
// BrowserPage 前端待承载的一个页面(登录对话框轮询用)。
|
||||
type BrowserPage struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
||||
Mode string `json:"mode"`
|
||||
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
||||
PageURL string `json:"page_url"`
|
||||
// Refetch 见 rule.BrowserTask.Refetch。
|
||||
Refetch bool `json:"refetch"`
|
||||
// SourceID 发起该请求的书源。
|
||||
SourceID string `json:"source_id"`
|
||||
// TargetURL 原始地址(data: 与超长地址不回传)。
|
||||
TargetURL string `json:"target_url,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserPageSnapshot 承载页面时返回的数据。
|
||||
type BrowserPageSnapshot struct {
|
||||
ID string
|
||||
HTML string
|
||||
SourceID string
|
||||
FinalURL string
|
||||
}
|
||||
|
||||
// pendingBrowser 一个待用户完成的页面。
|
||||
type pendingBrowser struct {
|
||||
id string
|
||||
sourceID string
|
||||
sourceURL string
|
||||
userID string
|
||||
request rule.BrowserTask
|
||||
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||
html string
|
||||
finalURL string
|
||||
mode string
|
||||
expires time.Time
|
||||
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
result rule.BrowserResult
|
||||
err error
|
||||
finished bool
|
||||
}
|
||||
|
||||
// browserHost 把服务层的浏览器面板绑定到某个书源会话(实现 rule.BrowserHost)。
|
||||
type browserHost struct {
|
||||
svc *ReaderService
|
||||
sourceURL string
|
||||
sourceID string
|
||||
userID string
|
||||
}
|
||||
|
||||
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
}
|
||||
|
||||
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
}
|
||||
|
||||
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||
if err != nil {
|
||||
return rule.BrowserResult{}, err
|
||||
}
|
||||
defer s.dropBrowser(entry.id)
|
||||
|
||||
timeout := time.NewTimer(browserAwaitTimeout)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case <-entry.done:
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
if entry.err != nil {
|
||||
return rule.BrowserResult{}, entry.err
|
||||
}
|
||||
return entry.result, nil
|
||||
case <-ctx.Done():
|
||||
return rule.BrowserResult{}, ctx.Err()
|
||||
case <-timeout.C:
|
||||
return rule.BrowserResult{}, fmt.Errorf("等待页面操作超时(%s),请重试", browserAwaitTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerBrowser 准备页面内容并登记待办。
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||
id := newBrowserID()
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if finalURL == "" {
|
||||
finalURL = req.URL
|
||||
}
|
||||
entry := &pendingBrowser{
|
||||
id: id,
|
||||
sourceID: sourceID,
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
expires: time.Now().Add(browserPageTTL),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.browserMu.Lock()
|
||||
if s.browserPending == nil {
|
||||
s.browserPending = map[string]*pendingBrowser{}
|
||||
}
|
||||
s.pruneBrowsersLocked()
|
||||
s.browserPending[id] = entry
|
||||
s.browserMu.Unlock()
|
||||
|
||||
if s.log != nil {
|
||||
s.log.Info("reader:待用户完成页面操作",
|
||||
zap.String("source", sourceID), zap.String("mode", mode), zap.String("title", req.Title))
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// browserBridgeScript 注入到承载页面里:父窗口请求时回传当前 DOM,把页面自己的
|
||||
// fetch / XMLHttpRequest 转交给父窗口代为请求,并补上沙箱里不可用的存储对象。
|
||||
//
|
||||
// 为什么不让父窗口直接读 contentDocument:那要求 iframe 同源,而书源页面是
|
||||
// 第三方 HTML,拿到同源权限就能读写 MeBox 自己的页面与 localStorage(JWT)。
|
||||
// 改成 postMessage 后,iframe 可以只开 allow-scripts(不透明源)。
|
||||
//
|
||||
// 为什么要补存储对象:不透明源里访问 localStorage / sessionStorage / document.cookie
|
||||
// 会直接抛 SecurityError,SPA 类的「用户后台」在启动读 token 时就崩了,
|
||||
// 表现是一直停在「正在安全加载」。这里用内存版兜住,既不给出同源权限,
|
||||
// 又让页面能正常跑完启动流程。
|
||||
//
|
||||
// 为什么要代发请求:iframe 是不透明源,页面自己的 XHR 既带不上书源 Cookie,
|
||||
// 也会被 CORS 拦掉——靠接口取数的页面会取不到数据。
|
||||
const browserBridgeScript = `<script data-mebox-browser-bridge="1">(function(){` +
|
||||
`if(window.__meboxBridgeInstalled){return}window.__meboxBridgeInstalled=true;` +
|
||||
// ── 沙箱内存储兜底 ──
|
||||
`function memStore(){var d={};return{` +
|
||||
`getItem:function(k){k=String(k);return Object.prototype.hasOwnProperty.call(d,k)?d[k]:null},` +
|
||||
`setItem:function(k,v){d[String(k)]=String(v)},` +
|
||||
`removeItem:function(k){delete d[String(k)]},` +
|
||||
`clear:function(){d={}},` +
|
||||
`key:function(i){var ks=Object.keys(d);return i>=0&&i<ks.length?ks[i]:null},` +
|
||||
`get length(){return Object.keys(d).length}}}` +
|
||||
`function patchStore(n){try{var s=memStore();Object.defineProperty(window,n,{configurable:true,get:function(){return s}})}catch(e){}}` +
|
||||
`patchStore('localStorage');patchStore('sessionStorage');` +
|
||||
// document.cookie 也要给:这类页面常用它判断是否已登录(真实 Cookie 在服务端,
|
||||
// 浏览器里没有)。把书源在该站点的 Cookie 预置进去,页面才会正常渲染。
|
||||
`var ck={};` +
|
||||
`(function(seed){String(seed||'').split(';').forEach(function(p){var i=p.indexOf('=');if(i>0){ck[p.slice(0,i).trim()]=p.slice(i+1).trim()}})})` + "(`__MEBOX_COOKIE__`)" + `;` +
|
||||
`try{Object.defineProperty(document,'cookie',{configurable:true,` +
|
||||
`get:function(){var o=[];for(var k in ck){o.push(k+'='+ck[k])}return o.join('; ')},` +
|
||||
`set:function(v){try{var p=String(v).split(';')[0].split('=');if(p[0]){ck[p[0].trim()]=p.slice(1).join('=')}}catch(e){}}})}catch(e){}` +
|
||||
// 书源页面自身的基地址:相对地址要按它解析,而不是按承载地址(我们自己的源)
|
||||
`var BASE=` + "`__MEBOX_BASE_URL__`" + `;` +
|
||||
`function abs(u){try{return new URL(u,BASE||location.href).href}catch(e){try{return new URL(u,location.href).href}catch(e2){return u}}}` +
|
||||
// ── DOM 回传 ──
|
||||
`window.addEventListener('message',function(e){` +
|
||||
`if(!e||e.data!=='__mebox_dom__'){return}` +
|
||||
`try{parent.postMessage({__mebox_dom__:true,html:document.documentElement.outerHTML},'*')}catch(err){}` +
|
||||
`},false);` +
|
||||
// ── 请求代理(父窗口持有登录态,由它转交服务端补 Cookie) ──
|
||||
`var seq=0,waiters={};` +
|
||||
`window.addEventListener('message',function(e){` +
|
||||
`var d=e&&e.data;` +
|
||||
`if(!d||d.__mebox_proxy_res__!==true){return}` +
|
||||
`var w=waiters[d.reqId];` +
|
||||
`if(!w){return}delete waiters[d.reqId];w(d);` +
|
||||
`},false);` +
|
||||
`function rpc(method,url,headers,body){` +
|
||||
`return new Promise(function(resolve,reject){` +
|
||||
`var id='r'+(++seq);waiters[id]=resolve;` +
|
||||
`try{parent.postMessage({__mebox_proxy__:true,reqId:id,url:url,method:method,headers:headers||{},body:body||''},'*')}catch(e){delete waiters[id];reject(e);return}` +
|
||||
`setTimeout(function(){if(waiters[id]){delete waiters[id];reject(new Error('mebox proxy timeout'))}},60000);` +
|
||||
`})}` +
|
||||
`function decode(res){return res&&res.base64?atob(res.body||''):((res&&res.body)||'')}` +
|
||||
`var origFetch=window.fetch;` +
|
||||
`if(origFetch){window.fetch=function(input,init){` +
|
||||
`init=init||{};` +
|
||||
`var url=typeof input==='string'?input:(input&&input.url);` +
|
||||
`if(!url){return origFetch.apply(this,arguments)}` +
|
||||
`var method=String(init.method||(input&&input.method)||'GET').toUpperCase();` +
|
||||
`var headers={};` +
|
||||
`try{new Headers(init.headers||(input&&input.headers)||{}).forEach(function(v,k){headers[k]=v})}catch(e){}` +
|
||||
`var body=init.body?String(init.body):'';` +
|
||||
`return rpc(method,abs(url),headers,body).then(function(res){` +
|
||||
`return new Response(decode(res),{status:(res&&res.status)||200,headers:{'Content-Type':(res&&res.contentType)||'text/plain'}})` +
|
||||
`})}}` +
|
||||
`var OrigXHR=window.XMLHttpRequest;` +
|
||||
`function ProxyXHR(){this._h={};this._m='GET';this._u='';this.readyState=0;this.status=0;this.response=null;this.responseText='';this.responseType=''}` +
|
||||
`ProxyXHR.prototype.open=function(m,u){this._m=m;this._u=u;this.readyState=1;this._fire('onreadystatechange')};` +
|
||||
`ProxyXHR.prototype.setRequestHeader=function(k,v){this._h[k]=v};` +
|
||||
`ProxyXHR.prototype._fire=function(n){try{if(this[n])this[n]()}catch(e){}};` +
|
||||
`ProxyXHR.prototype.send=function(body){` +
|
||||
`var self=this;` +
|
||||
`rpc(String(this._m).toUpperCase(),abs(this._u),this._h,body?String(body):'').then(function(res){` +
|
||||
`self.status=(res&&res.status)||200;self.readyState=4;self.responseText=decode(res);` +
|
||||
`if(self.responseType==='json'){try{self.response=JSON.parse(self.responseText)}catch(e){self.response=null}}else{self.response=self.responseText}` +
|
||||
`self._fire('onreadystatechange');self._fire('onload')` +
|
||||
`},function(){self.status=0;self.readyState=4;self._fire('onreadystatechange');self._fire('onerror')})};` +
|
||||
`ProxyXHR.prototype.getResponseHeader=function(){return null};` +
|
||||
`ProxyXHR.prototype.getAllResponseHeaders=function(){return ''};` +
|
||||
`ProxyXHR.prototype.abort=function(){};` +
|
||||
`window.XMLHttpRequest=ProxyXHR;` +
|
||||
`})();</script>`
|
||||
|
||||
// injectBrowserBridge 把回传/代理脚本插进页面 <head>(没有 head 就插在最前面),
|
||||
// 并把书源页面的基地址与 Cookie 填进脚本:前者供相对地址解析,后者供页面判断
|
||||
// 登录态(真实 Cookie 在服务端,页面看不到)。
|
||||
func injectBrowserBridge(html, baseURL, cookie string) string {
|
||||
if strings.Contains(html, `data-mebox-browser-bridge`) {
|
||||
return html
|
||||
}
|
||||
script := strings.ReplaceAll(browserBridgeScript, "__MEBOX_BASE_URL__", jsStringEscape(baseURL))
|
||||
script = strings.ReplaceAll(script, "__MEBOX_COOKIE__", jsStringEscape(cookie))
|
||||
lower := strings.ToLower(html)
|
||||
if i := strings.Index(lower, "<head>"); i >= 0 {
|
||||
pos := i + len("<head>")
|
||||
return html[:pos] + script + html[pos:]
|
||||
}
|
||||
if i := strings.Index(lower, "<html"); i >= 0 {
|
||||
if j := strings.Index(lower[i:], ">"); j >= 0 {
|
||||
pos := i + j + 1
|
||||
return html[:pos] + script + html[pos:]
|
||||
}
|
||||
}
|
||||
return script + html
|
||||
}
|
||||
|
||||
// jsStringEscape 转义要嵌进 JS 模板字符串的地址(反引号 / 反斜杠 / ${)。
|
||||
func jsStringEscape(s string) string {
|
||||
r := strings.NewReplacer("\\", "\\\\", "`", "\\`", "${", "\\${")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// prepareBrowserPage 得到 iframe 要承载的页面源码。
|
||||
//
|
||||
// 返回的页面里会注入回传/代理脚本;脚本需要两样东西:
|
||||
// - 基地址:解析页面里的相对地址(书源自带的 data: 页面没有真实地址,
|
||||
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
}
|
||||
if html := rule.ParseDataHTML(req.URL); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
}
|
||||
// 2) 外部地址:服务端带书源 Cookie 抓取,并把资源地址改写到代理
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||
}
|
||||
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
|
||||
if !strings.Contains(strings.ToLower(contentType), "html") {
|
||||
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
|
||||
wrapped := "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>" + htmlEscape(req.Title) +
|
||||
"</title></head><body><pre style=\"white-space:pre-wrap;word-break:break-all;padding:16px;" +
|
||||
"font:13px/1.6 ui-monospace,monospace\">" + htmlEscape(body) + "</pre></body></html>"
|
||||
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 "<!DOCTYPE html>" + 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] + "…"
|
||||
}
|
||||
@@ -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 = '<!DOCTYPE html><html><body><span id="serverValue">线路甲</span></body></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,
|
||||
`<html><body><span id="serverValue">线路乙</span></body></html>`, "", 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(`<html><body>请先登录</body></html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body><h1>我的账号</h1>` +
|
||||
`<img src="/avatar.png"><a href="/orders">订单</a>` +
|
||||
`<a href="https://other-site.net/x">站外</a></body></html>`))
|
||||
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(`<html><head></head><body>后台</body></html>`))
|
||||
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, "<span id=\"serverValue\">x</span>", "", false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("未解除阻塞")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,957 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:本地书籍(TXT / EPUB)导入与阅读的回归测试。
|
||||
|
||||
// newLocalBookService 复用登录测试的建库逻辑,另给一个临时 DataDir,
|
||||
// 避免本地书籍文件写到仓库目录里。
|
||||
func newLocalBookService(t *testing.T) *ReaderService {
|
||||
t.Helper()
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.App.DataDir = t.TempDir()
|
||||
return svc
|
||||
}
|
||||
|
||||
const sampleTXT = `书名:测试小说
|
||||
作者:某某
|
||||
|
||||
第1章 开端
|
||||
第一章的正文内容。
|
||||
这里还有一行。
|
||||
|
||||
第2章 发展
|
||||
第二章的正文内容。
|
||||
|
||||
第3章 结局
|
||||
第三章的正文内容。
|
||||
`
|
||||
|
||||
func TestSplitTXTChapters(t *testing.T) {
|
||||
chapters := splitTXTChapters(sampleTXT)
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("章节数 = %d,期望 3:%+v", len(chapters), chapters)
|
||||
}
|
||||
wantTitles := []string{"第1章 开端", "第2章 发展", "第3章 结局"}
|
||||
for i, c := range chapters {
|
||||
if c.Title != wantTitles[i] {
|
||||
t.Errorf("第 %d 章标题 = %q,期望 %q", i+1, c.Title, wantTitles[i])
|
||||
}
|
||||
}
|
||||
// 第一章要从文件头开始,把书名/作者并进去
|
||||
if chapters[0].Start != 0 {
|
||||
t.Errorf("第一章起点 = %d,期望 0", chapters[0].Start)
|
||||
}
|
||||
if chapters[len(chapters)-1].End != len(sampleTXT) {
|
||||
t.Errorf("末章终点 = %d,期望 %d", chapters[len(chapters)-1].End, len(sampleTXT))
|
||||
}
|
||||
// 区间必须首尾相接且不重叠
|
||||
for i := 1; i < len(chapters); i++ {
|
||||
if chapters[i].Start != chapters[i-1].End {
|
||||
t.Errorf("第 %d/%d 章区间不连续:%d vs %d", i, i+1, chapters[i-1].End, chapters[i].Start)
|
||||
}
|
||||
}
|
||||
// 每章正文要能切出来且包含该章内容
|
||||
body := sampleTXT[chapters[1].Start:chapters[1].End]
|
||||
if !strings.Contains(body, "第二章的正文内容") {
|
||||
t.Errorf("第二章区间内容不对: %q", body)
|
||||
}
|
||||
if strings.Contains(body, "第三章") {
|
||||
t.Errorf("第二章区间串到了第三章: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTXTChaptersFallback(t *testing.T) {
|
||||
chapters := splitTXTChapters("没有任何章节标记的一段文字。\n第二行。")
|
||||
if len(chapters) != 1 || chapters[0].Title != "全文" {
|
||||
t.Fatalf("无章标记应整本当一章: %+v", chapters)
|
||||
}
|
||||
if chapters[0].Start != 0 || chapters[0].End != len("没有任何章节标记的一段文字。\n第二行。") {
|
||||
t.Fatalf("整本区间不对: %+v", chapters[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTextFileGBK(t *testing.T) {
|
||||
// "第1章 开端" 的 GBK 编码
|
||||
gbk := []byte{0xB5, 0xDA, '1', 0xD5, 0xC2, ' ', 0xBF, 0xAA, 0xB6, 0xCB}
|
||||
got, charset := decodeTextFile(gbk)
|
||||
if charset != "gbk" {
|
||||
t.Fatalf("字符集 = %q,期望 gbk", charset)
|
||||
}
|
||||
if got != "第1章 开端" {
|
||||
t.Fatalf("解码结果 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportLocalBookAndRead(t *testing.T) {
|
||||
svc := newLocalBookService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
if !book.IsLocal || book.LocalPath == "" {
|
||||
t.Fatalf("导入后应标记为本地书籍: %+v", book)
|
||||
}
|
||||
if book.Name != "测试小说" {
|
||||
t.Errorf("书名 = %q", book.Name)
|
||||
}
|
||||
if book.TotalChapterNum != 3 {
|
||||
t.Errorf("总章数 = %d,期望 3", book.TotalChapterNum)
|
||||
}
|
||||
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil || len(chapters) != 3 {
|
||||
t.Fatalf("目录 = %d 章, err=%v", len(chapters), err)
|
||||
}
|
||||
|
||||
got, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("读第二章失败: %v", err)
|
||||
}
|
||||
if got.Type != "text" {
|
||||
t.Errorf("type = %q", got.Type)
|
||||
}
|
||||
if !strings.Contains(got.Content, "第二章的正文内容") {
|
||||
t.Errorf("第二章正文 = %q", got.Content)
|
||||
}
|
||||
if strings.Contains(got.Content, "第三章") || strings.Contains(got.Content, "第一章的正文") {
|
||||
t.Errorf("第二章正文串章了: %q", got.Content)
|
||||
}
|
||||
// 书名等前言应落在第一章
|
||||
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 _, err := svc.LocalChapterContent(ctx, "u1", book.ID, 9); err == nil {
|
||||
t.Error("越界章节应报错")
|
||||
}
|
||||
if _, err := svc.LocalChapterContent(ctx, "other", book.ID, 0); err == nil {
|
||||
t.Error("他人书架应拒绝")
|
||||
}
|
||||
|
||||
// 书架列表要带 is_local
|
||||
books, err := svc.ListBooks(ctx, "u1")
|
||||
if err != nil || len(books) != 1 || !books[0].IsLocal {
|
||||
t.Fatalf("书架列表未标记本地书籍: %+v, err=%v", books, err)
|
||||
}
|
||||
|
||||
// 重复导入同名文件:覆盖更新且保留进度
|
||||
if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第2章 发展"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
|
||||
if err != nil {
|
||||
t.Fatalf("重复导入失败: %v", err)
|
||||
}
|
||||
if again.ID != book.ID {
|
||||
t.Errorf("同名导入应覆盖同一本书,得到新 ID %s", again.ID)
|
||||
}
|
||||
if again.DurChapterIndex != 1 {
|
||||
t.Errorf("章节数不变时应保留进度,得到 %d", again.DurChapterIndex)
|
||||
}
|
||||
|
||||
// 移出书架要删掉落盘文件
|
||||
path, err := svc.localFilePath(again)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RemoveBook(ctx, "u1", again.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
if _, err := os.ReadFile(path); err == nil {
|
||||
t.Error("移出书架后本地文件应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
// epubFixture 造一个最小可用的 EPUB(NCX 目录 + 两个 XHTML 章节)。
|
||||
func epubFixture(t *testing.T) []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)
|
||||
}
|
||||
}
|
||||
add("mimetype", "application/epub+zip")
|
||||
add("META-INF/container.xml", `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`)
|
||||
add("OEBPS/content.opf", `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="c1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="c2" href="ch2.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="c1"/><itemref idref="c2"/>
|
||||
</spine>
|
||||
</package>`)
|
||||
add("OEBPS/toc.ncx", `<?xml version="1.0"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<navMap>
|
||||
<navPoint id="n1"><navLabel><text>第一章 起风</text></navLabel><content src="ch1.xhtml"/></navPoint>
|
||||
<navPoint id="n2"><navLabel><text>第二章 落雨</text></navLabel><content src="ch2.xhtml"/></navPoint>
|
||||
</navMap>
|
||||
</ncx>`)
|
||||
add("OEBPS/ch1.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head>
|
||||
<body><h1>第一章 起风</h1><p>第一段 & 实体。</p><p>第二段。</p><script>var x=1;</script></body></html>`)
|
||||
add("OEBPS/ch2.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml"><body><p>第二章正文。</p><br/><p>又一段。</p></body></html>`)
|
||||
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", `<?xml version="1.0"?><container version="1.0"
|
||||
xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`)
|
||||
add("OEBPS/content.opf", `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<manifest>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
<item id="c1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="i1" href="images/pic.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="c1"/></spine></package>`)
|
||||
add("OEBPS/nav.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<body><nav epub:type="toc"><ol><li><a href="ch1.xhtml">插图章</a></li></ol></nav></body></html>`)
|
||||
add("OEBPS/ch1.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml"><body><h1>插图章</h1>
|
||||
<p>图片前。</p><img src="images/pic.jpg" alt=""/><p>图片后。</p></body></html>`)
|
||||
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("<div>甲</div>\n<p>乙<br/>丙</p><style>p{}</style>")
|
||||
if got != "甲\n乙\n丙" {
|
||||
t.Fatalf("htmlToText = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseEPUBTitlesHandlesNestedNavPoint 网上不少 EPUB(Epubor 导出)navPoint
|
||||
// 漏了闭合标签,标题会整棵挂进上一个 navPoint。按固定层级解会丢一大半标题,
|
||||
// 这里要求深层节点也能收全。
|
||||
func TestParseEPUBTitlesHandlesNestedNavPoint(t *testing.T) {
|
||||
raw := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<docTitle><text>UnKnown</text></docTitle>
|
||||
<navMap>
|
||||
<navPoint id="id1"><navLabel><text>目录</text></navLabel><content src="text00000.html"/></navPoint>
|
||||
<navPoint id="id2"><navLabel><text>小狗钱钱1</text></navLabel><content src="text00002.html"/>
|
||||
<navPoint id="id3"><navLabel><text>童话与理财</text></navLabel><content src="text00005.html"/></navPoint>
|
||||
<navPoint id="id4"><navLabel><text>前言</text></navLabel><content src="text00006.html"/></navPoint>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>`)
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<body>
|
||||
<nav epub:type="toc"><h1>目录</h1><ol>
|
||||
<li><a href="ch1.xhtml">第一章 起风</a></li>
|
||||
<li><a href="ch2.xhtml#s1">第二章 落雨</a>
|
||||
<ol><li><a href="ch3.xhtml">第三章 天晴</a></li></ol>
|
||||
</li>
|
||||
</ol></nav>
|
||||
</body></html>`)
|
||||
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 := `<html><body><h1>第一章</h1>
|
||||
<p>正文一。</p>
|
||||
<img src="Image00024.jpg" style="width:100%;height:100%;" />
|
||||
<p>正文二<img src='sub/pic.png'/>尾巴。</p>
|
||||
<p><img src="http://cdn.example.com/a.jpg"/></p>
|
||||
</body></html>`
|
||||
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 用 <meta name="cover">;都为 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", `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`)
|
||||
meta := ""
|
||||
if epub2 {
|
||||
meta = `<metadata><meta name="cover" content="cover-img"/></metadata>`
|
||||
}
|
||||
props := ""
|
||||
if epub3 {
|
||||
props = ` properties="cover-image"`
|
||||
}
|
||||
add("OEBPS/content.opf", `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">`+meta+`
|
||||
<manifest>
|
||||
<item id="c1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="cover-img" href="images/cover.jpg" media-type="image/jpeg"`+props+`/>
|
||||
</manifest>
|
||||
<spine><itemref idref="c1"/></spine>
|
||||
</package>`)
|
||||
add("OEBPS/ch1.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml"><body><h1>正文</h1><p>内容。</p></body></html>`)
|
||||
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", `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`)
|
||||
// meta 指向一个 manifest 里不存在的 id
|
||||
add("OEBPS/content.opf", `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata><meta name="cover" content="not-exist"/></metadata>
|
||||
<manifest>
|
||||
<item id="c1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="pic" href="images/封面.png" media-type="image/png"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="c1"/></spine>
|
||||
</package>`)
|
||||
add("OEBPS/ch1.xhtml", `<html xmlns="http://www.w3.org/1999/xhtml"><body><p>内容。</p></body></html>`)
|
||||
// 声明失效,只能靠文件名(含“封面”)兜底
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 是
|
||||
// `<js>…</js>$.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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 := "<html><body>线路</body></html>"
|
||||
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: `<html><body><span id="serverValue">https://v2.example-aggregate.com</span></body></html>`,
|
||||
}, 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: "<html>用户后台</html>"}, 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 应请求重画表单")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件:data: 地址与「type 声明」——书源用它当参数信封。
|
||||
//
|
||||
// 聚合类书源(光遇聚合等)会把上一阶段的结果打包进一个 data: 地址当 URL 用:
|
||||
//
|
||||
// searchUrl → `data:;base64,<base64(参数JSON)>,{"type":"gysearch"}`
|
||||
// bookUrl → `data:;base64,<base64({book_id,sources,…})>,{"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)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:data: 参数信封 + URL 选项 type 的回归测试。
|
||||
//
|
||||
// 背景:聚合类书源把上一阶段的结果打包成
|
||||
// `data:;base64,<base64(参数JSON)>,{"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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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`。
|
||||
//
|
||||
// 典型触发(光遇聚合):搜索列表规则是
|
||||
//
|
||||
// <js>const { key, tab, sourcesKey, page, ... } = res; ...</js>$.data
|
||||
//
|
||||
// 而单本书的 bookUrl 规则是
|
||||
//
|
||||
// <js>let book_id = ...; let tab = result.tab || '小说'; ...</js>
|
||||
//
|
||||
// 两者在同一轮解析里先后执行,第二个必然编译失败;因为失败发生在「逐条取字段」
|
||||
// 阶段且被 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -50,6 +50,28 @@ function user() {
|
||||
java.startBrowserAwait(BaseUrl() + '/user', '用户后台');
|
||||
}
|
||||
|
||||
// 切换线路:与真实聚合源同构——把一段内嵌 HTML 交给宿主浏览器,
|
||||
// 用户在页面里点选,宿主回传「操作后的页面源码」,书源再从 DOM 里
|
||||
// 解析出所选线路写进源变量。(光遇聚合的 getServerSettings 即此结构)
|
||||
function switchLine() {
|
||||
let hostsbk = (getVariable('云端配置') || {})['hosts'] || hosts;
|
||||
let html = '<!DOCTYPE html><html><body>'
|
||||
+ '<span id="serverValue">' + BaseUrl() + '</span>'
|
||||
+ '<span id="autoSwitchValue">true</span>'
|
||||
+ '</body></html>';
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user