mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-16 04:44:23 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6646beea | |||
| 83ec494a36 | |||
| b8648f0049 | |||
| 5acfa6292a | |||
| 42343212c5 | |||
| 6584519bd8 | |||
| 565a55b194 | |||
| 9036ad04e7 | |||
| ce4319ec25 | |||
| c1ff55ffbf | |||
| fa41936095 | |||
| 9cc21a47c5 | |||
| 5976a8d310 | |||
| d0b4255aa8 | |||
| b8e6418326 | |||
| c84f2604ed | |||
| 1e6909ddbf |
@@ -1,2 +0,0 @@
|
||||
'Get-Content' is not recognized as an internal or external command,
|
||||
operable program or batch file.
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
- name: Download web/dist
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
# 产出:
|
||||
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
||||
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
||||
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
|
||||
# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
|
||||
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
|
||||
#
|
||||
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
|
||||
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
||||
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
|
||||
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
|
||||
# 不打 release tag、不发布 GitHub Release、不部署服务器。
|
||||
|
||||
name: Beta Build
|
||||
|
||||
@@ -22,9 +24,6 @@ permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
BETA_VERSION_PREFIX: beta
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1) 编译验证 + 多平台二进制产物
|
||||
@@ -32,17 +31,24 @@ jobs:
|
||||
test-and-build:
|
||||
name: Test & build artifacts
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
binary_version: ${{ steps.version.outputs.binary_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
|
||||
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
git fetch origin version || true
|
||||
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
|
||||
# before the Go toolchain touches the web package.
|
||||
@@ -59,7 +65,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
@@ -71,13 +77,13 @@ jobs:
|
||||
- name: go build (host)
|
||||
run: go build ./...
|
||||
|
||||
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
||||
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
|
||||
- name: Build linux/amd64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
|
||||
- name: Build linux/arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
|
||||
- name: Build windows/amd64
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -87,7 +93,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
|
||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
|
||||
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
docker-beta:
|
||||
name: Build & push beta Docker image
|
||||
@@ -98,13 +105,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -115,6 +115,15 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
type=raw,value=${{ needs.test-and-build.outputs.version }}
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -123,11 +132,9 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.repository }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.full_version }}
|
||||
VERSION=${{ needs.test-and-build.outputs.binary_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
||||
|
||||
+2
-1
@@ -80,4 +80,5 @@ media/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
.zcode/
|
||||
.tmp-src
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
web/dist/
|
||||
web/.vite/
|
||||
web/coverage/
|
||||
web/tsconfig.tsbuildinfo
|
||||
dist-release/
|
||||
|
||||
# Data / runtime
|
||||
data/
|
||||
cache/
|
||||
logs/
|
||||
.tmp-deploy-data/
|
||||
.tmp-deploy-smoke-data/
|
||||
.tmp-deploy-smoke-cache/
|
||||
.tmp-deploy-cache/
|
||||
.tmp-deploy-server.*
|
||||
.tmp-live-server.*
|
||||
.mebox.pid
|
||||
*.log
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Editor / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local configs (keep examples)
|
||||
config/secrets.yaml
|
||||
config.yaml
|
||||
|
||||
# WorkBuddy workspace (local AI assistant memory)
|
||||
.workbuddy/
|
||||
|
||||
# Editor backups
|
||||
*~
|
||||
.tmp_*
|
||||
|
||||
# Runtime / local-only artifacts (清理补充)
|
||||
.tmp/
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
# 本地开发运行产物
|
||||
.agents/
|
||||
.claude/
|
||||
.dev-cache/
|
||||
.dev-data/
|
||||
.dev-logs/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
|
||||
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
|
||||
.git/
|
||||
.hg/
|
||||
.svn/
|
||||
bower_components/
|
||||
jspm_packages/
|
||||
__pycache__/
|
||||
site-packages/
|
||||
venv/
|
||||
coverage/
|
||||
htmlcov/
|
||||
lcov-report/
|
||||
cmakefiles/
|
||||
cmake-build-*/
|
||||
bazel-*/
|
||||
pods/
|
||||
deriveddata/
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
test-results/
|
||||
allure-results/
|
||||
allure-report/
|
||||
cdk.out/
|
||||
*.egg-info/
|
||||
*.dist-info/
|
||||
eggs/
|
||||
pip-wheel-metadata/
|
||||
wheels/
|
||||
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Multi-architecture build for MeBox.
|
||||
#
|
||||
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
|
||||
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||
#
|
||||
# Build:
|
||||
@@ -25,7 +25,7 @@ COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// reader-smoke 是阅读书源兼容性冒烟工具:
|
||||
// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
|
||||
// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
func main() {
|
||||
file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
|
||||
urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
|
||||
key := flag.String("key", "斗破苍穹", "搜索关键词")
|
||||
concurrency := flag.Int("c", 4, "并发数")
|
||||
timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
|
||||
jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
|
||||
flag.Parse()
|
||||
|
||||
payload := ""
|
||||
switch {
|
||||
case *file != "":
|
||||
data, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fatal("读取文件失败: %v", err)
|
||||
}
|
||||
payload = string(data)
|
||||
case *urlFlag != "":
|
||||
client := helper.NewSiteHTTPClient(30, true)
|
||||
req, err := http.NewRequest("GET", *urlFlag, nil)
|
||||
if err != nil {
|
||||
fatal("构造请求失败: %v", err)
|
||||
}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fatal("拉取书源失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
sb.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
payload = sb.String()
|
||||
default:
|
||||
fatal("需要 -file 或 -url 指定书源来源")
|
||||
}
|
||||
|
||||
sources := reader.ParseSourcePayload(payload)
|
||||
if len(sources) == 0 {
|
||||
fatal("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
|
||||
ctx := context.Background()
|
||||
|
||||
results := make([]*reader.SmokeChainResult, len(sources))
|
||||
sem := make(chan struct{}, max(1, *concurrency))
|
||||
var wg sync.WaitGroup
|
||||
for i, raw := range sources {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, raw string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
|
||||
defer cancel()
|
||||
res := svc.SmokeSource(ctxSrc, raw, *key)
|
||||
results[i] = res
|
||||
status := "✓"
|
||||
if !res.OK {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
|
||||
status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
|
||||
}(i, raw)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 汇总
|
||||
var searchOK, infoOK, tocOK, contentOK, allOK int
|
||||
failedAt := map[string]int{}
|
||||
for _, r := range results {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
switch r.FailedAt {
|
||||
case "":
|
||||
allOK++
|
||||
searchOK++
|
||||
infoOK++
|
||||
tocOK++
|
||||
contentOK++
|
||||
case "search":
|
||||
failedAt["search"]++
|
||||
case "info":
|
||||
searchOK++
|
||||
failedAt["info"]++
|
||||
case "toc":
|
||||
searchOK++
|
||||
infoOK++
|
||||
failedAt["toc"]++
|
||||
case "content":
|
||||
searchOK++
|
||||
infoOK++
|
||||
tocOK++
|
||||
failedAt["content"]++
|
||||
}
|
||||
}
|
||||
n := len(results)
|
||||
pct := func(v int) string {
|
||||
if n == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
|
||||
}
|
||||
fmt.Printf("\n==== 冒烟报告 ====\n")
|
||||
fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
|
||||
fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
|
||||
fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
|
||||
fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
|
||||
fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
|
||||
fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
|
||||
for _, stage := range []string{"search", "info", "toc", "content"} {
|
||||
if failedAt[stage] > 0 {
|
||||
fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
|
||||
}
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
out, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fatal("序列化报告失败: %v", err)
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func stageCN(r *reader.SmokeChainResult) string {
|
||||
switch r.FailedAt {
|
||||
case "":
|
||||
return "完成"
|
||||
case "search":
|
||||
return "搜索"
|
||||
case "info":
|
||||
return "详情"
|
||||
case "toc":
|
||||
return "目录"
|
||||
case "content":
|
||||
return "正文"
|
||||
case "parse":
|
||||
return "解析"
|
||||
default:
|
||||
return r.FailedAt
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
rs := []rune(strings.TrimSpace(s))
|
||||
if len(rs) <= n {
|
||||
if len(rs) == 0 {
|
||||
return "(未命名)"
|
||||
}
|
||||
return string(rs)
|
||||
}
|
||||
return string(rs[:n]) + "…"
|
||||
}
|
||||
|
||||
func fatal(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#faf8f5" />
|
||||
<meta name="description" content="MeBox — 个人家庭媒体中心" />
|
||||
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
||||
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
|
||||
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
|
||||
<title>MeBox</title>
|
||||
<script type="module" crossorigin src="/assets/index-CcRvbC_F.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-B0Z9INg1.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-data-CD8zvwOM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-Cz-iIQjW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-motion-FEuwvkVZ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-D3Hf5Dnr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-nIJoh1kU.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BPz3c5Oc.css">
|
||||
</head>
|
||||
<body class="font-body antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
@@ -0,0 +1,144 @@
|
||||
# MeBox 阅读功能实施计划(legado 书源兼容)
|
||||
|
||||
> 分支:`feature/reading`
|
||||
> 目标:在首页增加「影视 / 阅读」模式切换,阅读模式完整兼容阅读 3.0(legado)书源体系,覆盖 **文本(bookSourceType=0)、音频(=1)、漫画/图片(=2)** 三类源。
|
||||
> 实现方式(用户明确要求):**样式与逻辑全部仿造 refgd/legado 本体**,不参考其他重实现项目;相当于用 Go + React 18 + TypeScript 5 重写该项目。
|
||||
> 界面与交互的唯一规格:`docs/reader-ui-spec.md`(从 legado 源码逐屏调研产出)。
|
||||
> 规则引擎的唯一语义基准:legado `app/src/main/java/io/legado/app/model/analyzeRule/` 源码,Go 侧逐方法移植对拍(源码克隆在 `C:\MyProject\_ref\legado`,仅作对照,不进入构建)。
|
||||
|
||||
## 1. 范围
|
||||
|
||||
**做:**
|
||||
- 首页「影视/阅读」切换,阅读模式下有独立首页(书架/搜索/发现/最近阅读)
|
||||
- legado 书源导入与管理(URL 导入、文本/JSON 粘贴导入、启停、分组、排序)
|
||||
- 三类书源的完整链路:搜索 → 详情 → 目录 → 正文/播放列表/图片列表
|
||||
- Go 侧规则引擎:CSS(jsoup 风格) / JSONPath / XPath / 正则 / 内嵌 JS 五种语法及其组合
|
||||
- 文本阅读器、音频播放器、漫画阅读器三套前端 UI
|
||||
- 追更、阅读进度同步(服务端存储,多端一致)、换源、替换净化规则
|
||||
|
||||
**不做(本期明确排除):**
|
||||
- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示)
|
||||
- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景;
|
||||
纯 JS / 表单类登录已支持,见 P4.5)
|
||||
- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3)
|
||||
- 本地 TXT/EPUB 导入(列为后续可选)
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
沿用 MeBox 现有分层,全部新增代码集中在:
|
||||
|
||||
```
|
||||
internal/
|
||||
model/ # 新增 5 张表,注册进 AllModels() 自动迁移
|
||||
repository/ # reader 相关 GORM 封装
|
||||
service/reader/ # 规则引擎 + 书源业务(核心新增,预计占全部后端代码 70%)
|
||||
handler/ # /api/reader/* 路由组
|
||||
web/src/
|
||||
pages/reader/ # 阅读端独立页面群(懒加载路由)
|
||||
components/reader/
|
||||
```
|
||||
|
||||
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
|
||||
|
||||
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
|
||||
|
||||
## 3. 规则引擎(核心工作)
|
||||
|
||||
语义基准:gedoor/legado `app/src/main/java/io/legado/app/model/analyzeRule/` 下的 AnalyzeRule / AnalyzeByJSoup / AnalyzeByJSonPath / AnalyzeByRegex / AnalyzeUrl,逐项对拍测试。
|
||||
|
||||
### 3.1 组件与选型
|
||||
|
||||
| 组件 | 选型 | 说明 |
|
||||
|---|---|---|
|
||||
| HTML/CSS | `PuerkitoBio/goquery` | jsoup 等价物;jsoup 特有语法(class.x / id.x / tag.x / text.x / children / @text / @textNodes / @html / 属性选择)自己包一层 |
|
||||
| XPath | `antchfx/htmlquery` | 对齐 JsoupXpath 语义 |
|
||||
| JSONPath | `PaesslerAG/jsonpath`(备选 ohler55/ojg) | Jayway 语义 + 自实现 `\|\|`/`&&` 合并层,选型阶段需验证 |
|
||||
| 正则 | Go regexp(RE2) | legado 部分源用 Java 正则语法,回退换 `dlclark/regexp2` |
|
||||
| JS | `dop251/goja` | ES2017+,跑书源内嵌 JS |
|
||||
| 字符集 | `golang.org/x/text` | GBK/GB18030 解码 |
|
||||
|
||||
### 3.2 引擎能力清单
|
||||
|
||||
- 规则模式识别:`@css:` / `$.`(JSONPath) / `@XPath:`或`//` / `<js>`与`@js:` / `##` 正则替换段
|
||||
- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}`
|
||||
- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义)
|
||||
- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`<js>` 生成 URL、URL 后 `,{...}` 选项(method/body/charset/headers/retry/timeout/type/proxy/js/webView)
|
||||
- JS 沙箱:goja 运行时 + 执行超时中断 + 禁止直接 IO;上下文注入 `java`、`source`、`book`、`baseUrl`、`result` 等对象
|
||||
- `java.*` 桥接函数(按书源实际使用频率分批实现):
|
||||
- 网络:ajax / ajaxAll / connect / get / post / head
|
||||
- 编解码:base64Decode/Encode(含 URL-safe)、hexDecode、encodeURI/decodeURI、htmlDecode
|
||||
- 加解密:md5(16/32)、sha1/sha256、AES/DES/3DES/RSA(CBC/ECB + 常见 padding/key 语义,legado 源里最常见的坑)
|
||||
- 字符串与时间:replaceAll/substring/正则族、timeFormat 等
|
||||
- 规则回调:`java.getString/getElement` 等,桥回 Go 规则引擎(JS 与规则互相嵌套的关键)
|
||||
|
||||
### 3.3 兼容策略
|
||||
|
||||
- 引擎按能力分层实现,每个能力配真实书源样本的单测(fixtures 放 `internal/service/reader/testdata/`)
|
||||
- 提供 CLI 冒烟工具(如 `cmd/reader-smoke`):对批量导入的公开书源集跑 搜索/详情/目录/正文 全链路,输出成功率报告,作为每个阶段验收依据
|
||||
- 含 `webView` 选项的源直接判定不兼容并在书源管理页标注
|
||||
|
||||
## 4. 数据模型(新增表)
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|---|---|
|
||||
| book_sources | name, group, type(0/1/2), source_url, json(原文), enabled, custom_order, last_check_at, comment |
|
||||
| books(书架) | source_url, book_url, name, author, cover_url, intro, kind, type(文本/音频/图片), latest_chapter, total_chapters, last_read_chapter_index, last_read_at |
|
||||
| book_chapters | book_id, index, title, url, is_volume, update_time |
|
||||
| read_progress | book_id(唯一), chapter_index, position(滚动/秒/图片序), updated_at |
|
||||
| replace_rules | name, find, replace, scope, is_regex, enabled, order |
|
||||
|
||||
阅读器显示设置(主题/字体/翻页方式)存前端 localStorage,不上服务端。
|
||||
|
||||
## 5. API 设计(/api/reader/*)
|
||||
|
||||
- 书源:`GET/POST/DELETE /sources`、`POST /sources/import`(URL 或 JSON/base64 文本,自动识别格式与类型)、`PATCH /sources/:id`(启停/排序)
|
||||
- 搜索:`POST /search {keyword}` → 后台聚合任务,结果经 WS `reader:search` 增量推送;结果可一键加入书架
|
||||
- 发现:`GET /explore?source=&group=`(解析 exploreUrl 的 `分组名::url` 结构)
|
||||
- 书架:`GET/POST/DELETE /books`、`GET /books/:id/info`、`GET /books/:id/toc`、`POST /books/:id/refresh`(追更)
|
||||
- 本地书籍:`POST /local/books`(multipart 上传 TXT/EPUB,导入即入书架)
|
||||
- 内容:`GET /books/:id/chapters/:idx/content` —— 按书籍类型返回:
|
||||
- 文本:`{type:"text", content:"..."}`(服务端已合并 nextContentUrl 翻页、已应用替换规则)
|
||||
- 音频:`{type:"audio", tracks:[{url,title}]}`(含代理路径与所需请求头)
|
||||
- 图片:`{type:"image", images:[{url, style}]}`(同样经代理)
|
||||
- 进度:`PUT /books/:id/progress`
|
||||
- 替换规则:`/replace-rules` CRUD
|
||||
- 调试:`POST /debug {source_id, rule, url}`(书源调试器后端)
|
||||
- 图片/流代理:复用现有 image_proxy / stream_proxy 模式,按源配置注入 UA/Referer
|
||||
|
||||
## 6. 前端设计
|
||||
|
||||
- **首页切换**:`HomePage.tsx` 顶部加分段控件(仿 `LibraryTagBar` tab 模式),「阅读」切到阅读首页;选择持久化(zustand + localStorage)
|
||||
- **阅读首页**:继续阅读横排 + 书架封面网格 + 搜索入口 + 追更提示
|
||||
- **页面群**(懒加载,仿 `appRoutes.tsx`):`/reader`(首页)、`/reader/search`(多源并发搜索 + 实时进度)、`/reader/explore`、`/reader/book/:id`(详情 + 目录 + 换源)、`/reader/sources`(书源管理 + 调试器)
|
||||
- **三套阅读器**:
|
||||
- 文本:滚动 + 分页双模式(CSS 分栏测量分页)、主题(含夜间)、字体/行距/边距、点击翻页区、章节预加载、进度上报
|
||||
- 音频:`hls.js`(已是依赖)+ `<audio>` 兜底,播放列表、倍速、记忆进度、锁屏/息屏策略
|
||||
- 漫画:上下滚动 / 左右翻页双模式、相邻图片预加载、`imageStyle` 支持
|
||||
|
||||
## 7. 阶段划分
|
||||
|
||||
| 阶段 | 内容 | 交付物 |
|
||||
|---|---|---|
|
||||
| P0 引擎地基 ✅ | 规则引擎核心(四分析器 + 规则拆分/组合/变量)+ AnalyzeUrl v1(GET/POST/charset/headers/变量/页码模式)+ 表结构 + 书源导入/管理 API + 搜索/详情/目录/正文/书架/进度/调试 API | 已完成:`internal/service/reader/rule/`(规则引擎,~2800 行,对齐 AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer)+ 服务层 + `/api/reader/*` 路由 + 单测/端到端测试全绿 |
|
||||
| P1 文本源全链路 + 首页切换 | 搜索聚合(WS 进度)/详情/目录/正文(nextContentUrl 合并、缓存)+ 前端首页切换、书架、搜索、详情、文本阅读器 v1(阅读器样式仿 legado:9 宫格点击、主题、翻页动画) | 用纯规则型文本源完成「搜书→加入→阅读」全流程 |
|
||||
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
|
||||
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 听书面板(hls.js 播 m3u8、直链 `<audio>`、上一章/-15s/播放暂停/+15s/下一章、章节选择抽屉、定时关闭 0–180 分钟、倍速 0.5–3.0、跳过片头片尾按书持久化 `PUT /api/reader/books/:id/audio-config`、进度按秒记忆、播完/片尾到点自动下一章) | 音频源可听 |
|
||||
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
|
||||
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
|
||||
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
|
||||
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
|
||||
|
||||
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
|
||||
|
||||
## 8. 风险与对策
|
||||
|
||||
- **书源质量参差**:单源解析全程超时与重试上限;正文翻页循环设页数上限防死循环
|
||||
- **goja 性能**:复杂 JS 源解析慢 → 正文/目录强缓存、单源并发限 1、全局执行预算
|
||||
- **Java 正则/JSoup 细节差异**:以对拍测试驱动修正,regex 备选 regexp2
|
||||
- **热源防盗链**:图片与音频一律走带请求头的代理
|
||||
- **合规**:不内置任何书源,全部由用户自行导入,管理页附免责说明
|
||||
|
||||
## 9. 参考
|
||||
|
||||
- **唯一语义与样式基准**:https://github.com/refgd/legado (规则引擎 analyzeRule 源码 + UI 布局/交互,见 docs/reader-ui-spec.md)
|
||||
- 书源规则教程:https://mgz0227.github.io/The-tutorial-of-Legado/
|
||||
@@ -0,0 +1,247 @@
|
||||
# legado UI/交互仿制规格(React 18 + Tailwind Web 版实现依据)
|
||||
|
||||
> 来源:对 `refgd/legado`(克隆于 `C:\MyProject\_ref\legado`)源码的调研。
|
||||
> 路径缩写:`<src>` = `app/src/main/java/io/legado/app/`,布局在 `app/src/main/res/layout/`。
|
||||
|
||||
## 1. 首页/书架
|
||||
|
||||
文件:`<src>ui/main/bookshelf/`(BookshelfFragment、BooksAdapterGrid/List)、`fragment_bookshelf1/2.xml`、`item_bookshelf_grid*.xml`、`item_bookshelf_list*.xml`。主界面底部 BottomNavigation(书架/发现/我的)+ 右上悬浮搜索按钮。
|
||||
|
||||
**布局结构(从上到下)**
|
||||
- style1(标签页风格):标题行(书架标题 + 下拉箭头,点开分组切换菜单)→ 搜索按钮 + 更多按钮 → 一级分组 tab 条(tab 长按可删分组)→ 次级分组扩展标签 → ViewPager(每 tab 一个书籍列表/网格)。
|
||||
- style2(单 RecyclerView 混排):title_bar → SwipeRefresh → 书籍列表(根分组时「分组网格项 + 书籍项」混排,进入分组只显示书籍)→ 空态文案。
|
||||
- 网格项:封面 + 本地角标 + 未读数角标 + 加载中 + 书名 + 长按遮罩。
|
||||
- 列表项:封面 + 本地图标 + 有更新标记 + 未读数 + 书名 + 作者 + 最后更新时间 + 当前读到(章节名)+ 最新章节(章节名)+ 长按遮罩。
|
||||
|
||||
**交互逻辑**
|
||||
- 内置分组 id:全部(IdRoot=-100)、本地(IdLocal=-2)、未分组(IdUngrouped=-4),其余用户分组。
|
||||
- 单击书 → 直接进入阅读;单击分组 → 进入该分组。
|
||||
- 长按书 → 跳转书籍详情页;长按分组 → 重命名/删除对话框;style1 长按 tab 删除分组。
|
||||
- 右上菜单:搜索、Wi-Fi 传书、刷新目录、书架布局切换、分组管理、导出/导入书架、下载离线、本地导入、网址添加、日志。
|
||||
|
||||
**MeBox 的「影视 / 阅读」首页模式**(`stores/readerSettings.homeMode` + 顶栏 `LayoutReaderModeToggle`)
|
||||
- 切换入口在**顶栏**,位于搜索框与账号菜单之间,**只显示图标不显示文字**:影视模式显示场记板图标,阅读模式显示书图标(并带品牌色高亮),点一下切到另一个模块、图标随之变化。悬停提示写明「当前是 X 模式,点击切换到 Y」,不在首页时点击会先跳回 `/`。
|
||||
- 两种模式共用一个首页路由 `/`:影视模式渲染媒体首页,阅读模式渲染书架(`ReaderHomeContent embedded`)。首页内容区不再放分段式「影视 / 阅读」开关(原 `ReaderModeSwitch` 仅留给独立布局的 `/reader` 首页兜底)。
|
||||
- 切到阅读模式时,顶部搜索框换成**书搜索**(`LayoutHeaderBookSearch`,占位「搜索书籍…」):聚焦即下拉书架(本地即时过滤书名/作者,点条目进阅读器),回车优先打开书架首条;下拉底部固定一行「在书源中搜索「xxx」」,带 `?key=` 跳到 `/reader/search` 由多源聚合搜索页自动开搜。账号/主题菜单照常显示。
|
||||
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
|
||||
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
|
||||
|
||||
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
|
||||
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
|
||||
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
|
||||
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
|
||||
- 显示项开关:未读章数徽标、更新时间。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
|
||||
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
|
||||
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
|
||||
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
|
||||
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
|
||||
|
||||
## 2. 阅读界面(重点)
|
||||
|
||||
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
|
||||
|
||||
**布局结构(单 FrameLayout 栈)**:read_view 正文 → 文字选择光标 → read_menu 主菜单 → search_menu 全文搜索 → 朗读回原文浮条。
|
||||
|
||||
**正文渲染**
|
||||
- ReadView 内含 3 个 PageView(prev/cur/next 缓存),ContentTextView 纯 Canvas 自绘:按字号/行距/段距/缩进/两端对齐排版,Web 版用 CSS multi-column 或 JS 分页等效实现。
|
||||
- 翻页动画(0–5):0 覆盖、1 平移、2 仿真、3 上下滚动、4 无、5 平移覆盖;速度可调(默认 300ms)。
|
||||
- 滚动模式支持背景跟随、自动翻页(定时滚动/定时翻页)。
|
||||
- 图片/漫画书切换独立配置集 + MangaMenu:顶栏 + 底部胶囊(上一页/页码进度条/下一页),支持横滚、缩放。
|
||||
|
||||
**点击区域(9 宫格,边缘留 pageTouchClick px)**
|
||||
- 区块:左上/中上/右上/左中/中/右中/左下/中下/右下。
|
||||
- 默认:左列与上中=上一页;右列与下中=下一页;正中=呼出菜单。
|
||||
- 全部可配动作:0 呼出菜单、1 下一页、2 上一页、3 下一章、4 上一章、7 书签、10 目录、11 搜索、12 同步进度等。
|
||||
|
||||
**主菜单 ReadMenu(自上而下)**
|
||||
- 半透明遮罩(点击收起)。
|
||||
- 顶部 title_bar:返回 + 书名(点击进详情)+ 章节名/章节链接(点击打开原页面)+ 书源按钮(弹菜单:编辑书源/禁用书源等)。
|
||||
- 底部 bottom_menu(圆角面板):上一章 | 进度条 | 下一章 → 亮度条 + 自动亮度 → 快捷按钮行(搜索、自动翻页、替换规则、夜间/日间切换)→ 动作面板行(目录、朗读、界面、设置)。
|
||||
- 菜单配色可跟随页面背景或用主题底色,透明度 35–100。
|
||||
|
||||
**设置面板**
|
||||
- 「界面」三 Tab:文本(字体文件、字重、字号 5–50、字距、行距 0–20、段距、缩进、下划线、阴影);样式(白天/夜间/E-Ink 三态主题:文字色、背景色/图、强调色、菜单底色、菜单透明度、背景透明度,可恢复/管理/分享);页面(6 种翻页动画、状态栏深浅、滚动背景跟随、四边距、页眉页脚提示、简繁转换)。
|
||||
- 「设置」:屏幕方向、保持亮屏、隐藏状态栏、两端/底部对齐、竖排、双页、进度条行为(按页/按章)、音量键/滚轮翻页、点击区域配置、动画速度、自动换源等。
|
||||
- 页眉页脚提示项:无/章节名/时间/电量/电量百分比/页码/总进度/页码+总页/时间+电量/书名;页眉默认 左=时间/右=电量,页脚默认 左=章节名/右=页码/总页。
|
||||
|
||||
**内置主题与配色(assets/defaultData/readConfig.json,6 套 + 自定义)**
|
||||
|
||||
| 名称 | 背景(日/夜) | 文字(日/夜) | 强调(日/夜) | 字号 |
|
||||
|---|---|---|---|---|
|
||||
| 微信读书 | #FFC0EDC6 / #000000 | #FF0B0B0B / #ADADAD | #E53935 / #FE4D55 | 24 |
|
||||
| 预设1 | #FFFFFF / #000000 | #000000 / #FFFFFF | #E53935 / #FE4D55 | 20 |
|
||||
| 预设2(羊皮纸) | #DDC090 / #3C3F43 | #3E3422 / #DCDFE1 | #834E00 / #FE4D55 | 20 |
|
||||
| 预设3(护眼绿) | #C2D8AA / #3C3F43 | #596C44 / #88C16F | #E53935 / #FE4D55 | 20 |
|
||||
| 预设4(粉紫) | #DBB8E2 / #3C3F43 | #68516C / #F6AEAE | #801314 / #90BFF5 | 20 |
|
||||
| 预设5(淡蓝) | #ABCEE0 / #3C3F43 | #3D4C54 / #90BFF5 | #E53935 / #FE4D55 | 20 |
|
||||
|
||||
默认排版:textSize 20、letterSpacing 0.1、lineSpacingExtra 12、paragraphSpacing 2、缩进「 」、padding 上下6/左右16、页脚线 true。未选样式时默认:bg #EEEEEE / 夜 #000000 / E-Ink #FFFFFF,文字 #3E3D3B / 夜 #ADADAD,强调 #E53935 / 夜 #FE4D55。
|
||||
|
||||
**MeBox 文本阅读器已实现的排版/菜单细节**(`ReaderViewPage.tsx`)
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
- 入口:书架页右上「本地导入」按钮(书架为空时另有「上传本地书籍」),支持 TXT / EPUB,单文件上限 64MB,上传后自动入库并直接进入阅读页。
|
||||
- 书架卡片:本地书打「本地」角标,右上角有删除按钮(二次确认),删除会连服务器上的文件一起清掉。
|
||||
- 存储:正文落盘 `data/reader/local/<bookID>.<txt|epub>`;目录信息与网络书共用 `reader_chapters`,用 `Tag` 记定位:TXT 存 UTF-8 规范化后文件内的字节区间 `start:end`,EPUB 存 zip 内的 XHTML 条目路径。读章只取所需区间/条目,不整本载入内存。
|
||||
- TXT:自动识别 BOM(UTF-8/UTF-16) 与 UTF-8 / GBK / Big5,统一转 UTF-8 落盘;目录按 legado 默认 TXT 规则切章(`第X章/节/卷/集/部/篇`、序章、楔子、番外等),并对「第一章的正文内容」这类正文行做启发式过滤,切不出章名时整本当一章「全文」,章前内容(书名/简介)并入第一章。
|
||||
- EPUB:`META-INF/container.xml` → OPF → `spine` 顺序出章。目录标题优先取 NCX / EPUB3 NAV,**逐 token 走并用栈收任意层级的 navPoint**——Epubor 等工具导出的 EPUB 常漏 `</navPoint>`,标题会整棵嵌进上一个节点,按固定层级解会丢掉一大半标题;取不到时依次退回正文首行 → 封面页(文件名含 cover 且该页只有图)标「封面」→ `<title>`(过滤 Cover/Table of Contents 这类无信息量的)→ `第 N 章`。
|
||||
- EPUB 图片:正文里的 `<img>` 在转纯文本时就地换成 `[img]<zip 条目>` 标记行(相对路径按该 XHTML 所在目录解析),下发前把标记换成签名地址 `/api/reader/local/asset?b=&p=&s=`(HMAC,`<img>` 带不了 JWT),前端把 `[img]` 开头的行渲染成居中图片(`max-width:100%` + `max-height:70vh`,保证不撑破分栏)。网络书正文不受影响。
|
||||
- 进度:与网络书同一套 `durChapter*` 字段,跨端一致;同名文件重复上传按覆盖更新处理(章数不变则保留进度)。
|
||||
|
||||
**音频书播放条(AudioPlayActivity + AudioPlayService,即「听书」)**
|
||||
- 背景与封面:书籍封面强模糊(blur 32px)铺满做底,叠一层很淡的主题底色(opacity 0.3)保住日/夜对比度;正中圆形显示封面原图(对应 legado `upCover` 的 ivBg 模糊图 + ivCover 圆图)。封面缺失或加载失败时退回主题色圆点,不留破图。
|
||||
- transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章(SEEK_STEP=15s,进度按秒)。
|
||||
- 动作行(常驻底部,抽屉打开时仍可点):章节(目录选择,见下)、定时关闭、倍速、片头片尾。
|
||||
- 章节选择:底部抽屉列全部章节,定位到当前章、当前章高亮、点章即跳;卷名行不可点。
|
||||
- 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。
|
||||
- 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。
|
||||
- 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
|
||||
- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
|
||||
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
|
||||
|
||||
## 3. 搜索
|
||||
|
||||
文件:`<src>ui/book/search/` + `<src>model/webBook/SearchModel.kt`。
|
||||
|
||||
- 布局:顶部 SearchView + 转圈进度条 → 结果流 → 书架命中提示卡 → 搜索历史 chips + 清空 → 右下 开始/停止 悬浮按钮。
|
||||
- 结果项:封面、在书架角标、书源数角标(可换源数)、书名、作者、分类标签、最新章节、简介。
|
||||
- 多源并发:所有启用书源(按搜索范围过滤),固定线程池并发(全局设置 threadCount),单源 30s 超时。
|
||||
- 实时结果流:每源返回即刷新列表;结束后 onSearchFinish(isEmpty, hasMore)(任一源有结果即可翻页,searchPage++)。
|
||||
- 聚合去重 mergeItems 四档:书名或作者等于关键词 > kind 含关键词 > 书名/作者含关键词 > 其他;同名同作者合并为一项并 addOrigin(角标显示可换源数);档内按书源数降序。
|
||||
- 顶部菜单:精准搜索开关、搜索范围(分组/全选)、书源管理、日志。点结果 → 换源对话框/直接打开。
|
||||
|
||||
## 4. 发现
|
||||
|
||||
`<src>ui/main/explore/` + `ui/book/explore/`。布局:标题栏 → SwipeRefresh → 源选择行(当前源名 + 下拉、源内搜索、标签筛选、更多)→ 二级筛选条 → 分类标签条(横滑 chip,可展开)→ 书籍网格(封面+书名)→ 兜底列表。数据来自启用书源的 exploreUrl + ruleExplore;切源刷新标签;点标签拼 URL 加载、分页加载更多。
|
||||
|
||||
## 5. 书籍详情
|
||||
|
||||
`<src>ui/book/info/BookInfoActivity.kt`。布局:模糊封面背景 → 标题栏(返回/刷新/菜单)→ 下拉刷新 → 滚动区:封面 + 书名 + 作者 + 最新章节(点击刷新目录)+ 阅读时长 + 分类标签 + 分组行 + 书源行(换源)+ 目录入口行 → 双 Tab(简介 / 目录预览,可全屏、点章节跳读)→ 底部动作区:tv_shelf 加入/移出书架(描边按钮)+ tv_read 开始阅读/继续阅读(实心主色按钮)。菜单含置顶、去书源网页、登录、删除(勾选删缓存)、分享。
|
||||
|
||||
## 6. 目录页
|
||||
|
||||
`<src>ui/book/toc/`。布局:标题栏(返回 + 书名 + SearchView 搜章节)→ Tab(章节/书签)→ 章节 RecyclerView + 底部信息条(当前位置 + 跳顶/跳底)。菜单:倒序开关、使用净化替换、加载字数、长章分卷合并、TXT 目录规则。倒序即列表反转;卷名行可折叠;已读章节变色;点击章节回传 index 跳读。
|
||||
|
||||
## 7. 书源管理
|
||||
|
||||
- **主列表**:标题栏(SearchView 过滤 + 菜单)→ RecyclerView(item:域名、复选、启停 Switch、编辑、更多菜单、发现、快速调试、响应耗时条)→ 多选操作栏。菜单:排序(手动/自动/名称/URL/更新时间/响应时间/启用)、分组筛选(启用/禁用/需登录/无分组/启用发现/禁用发现)、添加书源(本地/网络/二维码)。
|
||||
- **编辑**:标题栏(保存/调试入口)→ 类型下拉 + 六个开关(启用/启用发现/CookieJar/段评/事件监听/自定义按钮)→ 六个 Tab:基本/搜索/发现/详情/目录/正文,每 Tab 为「标签 + 输入框 + 帮助弹层」表单。
|
||||
- **调试**:顶部 SearchView 输入关键词 → 串行执行 搜索→详情→目录→正文,日志逐条流式显示;可查看各阶段原始 HTML、切换探索分类调试。
|
||||
|
||||
## 8. 替换规则
|
||||
|
||||
- 列表页:标题栏(搜索 + 菜单:分组管理、启停筛选、添加、导入)→ RecyclerView(名称、分组、内容摘要、启停 Switch)→ 多选操作栏。
|
||||
- 编辑页字段:规则名 → 分组 → 替换规则(+ 正则开关 + 帮助)→ 替换为 → 作用于标题 / 作用于正文 → 作用范围(书名) → 排除范围 → 超时毫秒(order 隐含)。
|
||||
|
||||
## 9. 数据实体字段全集
|
||||
|
||||
**BookSource.kt**(rule* 为内嵌对象)
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| bookSourceUrl | String | "" |
|
||||
| bookSourceName | String | "" |
|
||||
| bookSourceGroup | String? | null |
|
||||
| bookSourceType | Int (0文本/1音频/2图片/3文件/4视频) | 0 |
|
||||
| bookUrlPattern | String? | null |
|
||||
| customOrder | Int | 0 |
|
||||
| enabled | Boolean | true |
|
||||
| jsLib | String? | null |
|
||||
| enabledExplore | Boolean | true |
|
||||
| enabledCookieJar | Boolean? | true |
|
||||
| concurrentRate | String? | null |
|
||||
| header | String? | null |
|
||||
| loginUrl / loginUi / loginCheckJs / coverDecodeJs | String? | null |
|
||||
| bookSourceComment | String? | null |
|
||||
| variableComment | String? | null |
|
||||
| lastUpdateTime | Long | 0 |
|
||||
| respondTime | Long | 180000 |
|
||||
| weight | Int | 0 |
|
||||
| exploreUrl / exploreScreen | String? | null |
|
||||
| ruleExplore / ruleSearch / ruleBookInfo / ruleToc / ruleContent / ruleReview | 对象 | null |
|
||||
| eventListener / customButton | Boolean | false |
|
||||
|
||||
**Book.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| bookUrl (PK) / tocUrl | String | "" / "" |
|
||||
| origin / originName | String | localTag / "" |
|
||||
| name / author | String | "" / "" |
|
||||
| kind / customTag / coverUrl / customCoverUrl / intro / customIntro / charset | String? | null |
|
||||
| type | Int (BookType) | text |
|
||||
| group | Long | 0 |
|
||||
| latestChapterTitle | String? | null |
|
||||
| latestChapterTime / lastCheckTime | Long | now |
|
||||
| lastCheckCount / totalChapterNum | Int | 0 |
|
||||
| durChapterTitle | String? | null |
|
||||
| durChapterIndex / durVolumeIndex / chapterInVolumeIndex / durChapterPos | Int | 0 |
|
||||
| durChapterTime | Long | now |
|
||||
| wordCount | String? | null |
|
||||
| canUpdate | Boolean | true |
|
||||
| order / originOrder | Int | 0 |
|
||||
| variable | String? | null |
|
||||
| readConfig(ReadConfig 内嵌) | reverseToc、pageAnim、reSegment、imageStyle、useReplaceRule、delTag、ttsEngine、splitLongChapter=true、readSimulating、startDate、startChapter、dailyChapters=3、openCredits=0、closeCredits=0、playMode=0、playSpeed=1.0、manga* 系列、mangaPageAnim | |
|
||||
|
||||
**BookChapter.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| url / title | String | "" / "" |
|
||||
| isVolume | Boolean | false |
|
||||
| baseUrl / bookUrl | String | "" / "" |
|
||||
| index | Int | 0 |
|
||||
| isVip / isPay | Boolean | false |
|
||||
| resourceUrl / tag / wordCount | String? | null |
|
||||
| start / end | Long? | null |
|
||||
| startFragmentId / endFragmentId / variable / imgUrl | String? | null |
|
||||
|
||||
**ReplaceRule.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| id | Long | now |
|
||||
| name | String | "" |
|
||||
| group | String? | null |
|
||||
| pattern | String | "" |
|
||||
| replacement | String | "" |
|
||||
| scope | String? | null |
|
||||
| scopeTitle | Boolean | false |
|
||||
| scopeContent | Boolean | true |
|
||||
| excludeScope | String? | null |
|
||||
| isEnabled | Boolean | true |
|
||||
| isRegex | Boolean | true |
|
||||
| timeoutMillisecond | Long | 3000 |
|
||||
| order | Int | Int.MIN_VALUE |
|
||||
|
||||
## 10. 主题与视觉
|
||||
|
||||
- 全局:主背景 `#F7F7FA`、主文字 `#DE000000`、品牌色 `#FFF6FBF8`(浅绿白调)。
|
||||
- 界面主题由 ThemeConfig(themes.json)控制 6 套;阅读菜单/底栏用 bottomBackground(跟随所选界面主题),沉浸模式(readBarStyleFollowPage)直接取页面背景/文字色。
|
||||
- 强调色 accentColor 用于选中态、光标、Tab 选中;E-Ink 模式菜单改描边。
|
||||
- 视觉风格:大圆角面板、1dp 描边、玻璃拟态半透明条、图标+文字标签的动作面板。
|
||||
|
||||
## Web 版实现要点
|
||||
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/truewhile/MeBox
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
@@ -19,9 +19,9 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -30,6 +30,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PaesslerAG/gval v1.0.0 // indirect
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.13.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/antchfx/htmlquery v1.3.6 // indirect
|
||||
github.com/antchfx/xpath v1.3.6 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -38,6 +44,9 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
@@ -47,9 +56,12 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -90,8 +102,8 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
|
||||
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
|
||||
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -24,6 +37,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
|
||||
@@ -54,6 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
@@ -62,7 +83,11 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -177,6 +202,7 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -191,33 +217,104 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
@@ -86,7 +86,9 @@ func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[
|
||||
if err != nil || m == nil {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
|
||||
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
|
||||
raw := service.MediaSTRMTarget(m)
|
||||
if raw == "" || !service.IsStrmMediaRow(m) {
|
||||
return
|
||||
}
|
||||
@@ -409,9 +411,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
|
||||
// 转码播放下每个分片请求都跑一遍太浪费。
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -436,9 +439,8 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,6 +26,20 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
||||
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||
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))
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
@@ -173,10 +175,20 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
|
||||
if err != nil || u == nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("external playback token: user lookup failed",
|
||||
zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
|
||||
if err != nil {
|
||||
// 静默返回空串会让 stream_url 变成 ?token=(空)、外部播放器只看到
|
||||
// 401 无从排查,至少在服务端日志里留下原因。
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("issue external playback token failed",
|
||||
zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
|
||||
@@ -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,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
|
||||
//
|
||||
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
|
||||
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
|
||||
|
||||
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: bookURL,
|
||||
}, name, "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"groups": groups})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func namesOf(groups []model.BookGroupSet) string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, g.Name)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func idsOf(groups []model.BookGroupSet, name string) string {
|
||||
for _, g := range groups {
|
||||
if g.Name == name {
|
||||
return strings.Join(g.BookIDs, ",")
|
||||
}
|
||||
}
|
||||
return "<missing>"
|
||||
}
|
||||
|
||||
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
|
||||
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须是 [] 而不是 null
|
||||
if !strings.Contains(w.Body.String(), `"groups":[]`) {
|
||||
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
|
||||
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
|
||||
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
|
||||
{Name: "玄幻", BookIDs: []string{b3.ID}},
|
||||
{Name: "空组", BookIDs: []string{}},
|
||||
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
|
||||
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
|
||||
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
|
||||
}
|
||||
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
|
||||
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
|
||||
}
|
||||
if ids := idsOf(got, "在读"); ids != "" {
|
||||
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
|
||||
}
|
||||
if ids := idsOf(got, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
if ids := idsOf(got, "外来"); ids != "" {
|
||||
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
|
||||
}
|
||||
|
||||
// 读回来应当一致
|
||||
loaded, code := getBookGroups(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("读回 status=%d", code)
|
||||
}
|
||||
if names := namesOf(loaded); names != namesOf(got) {
|
||||
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
|
||||
}
|
||||
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
|
||||
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := getBookGroups(t, router, "u1")
|
||||
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
|
||||
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsArePerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u2 看不到 u1 的分组
|
||||
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
|
||||
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
|
||||
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
|
||||
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
|
||||
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
|
||||
}
|
||||
|
||||
// u1 的分组不受影响
|
||||
back, _ := getBookGroups(t, router, "u1")
|
||||
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
|
||||
t.Fatalf("u1 分组被 u2 影响: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
|
||||
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "旧组", BookIDs: []string{b1.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "新组", BookIDs: []string{}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "新组" {
|
||||
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
|
||||
}
|
||||
|
||||
// 清空分组
|
||||
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
|
||||
t.Fatalf("清空后读回 = %+v,期望空", groups)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
|
||||
|
||||
func newReaderHandlerContainer(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
// 唯一库名 + cache=shared:阅读链路会并发写会话状态,
|
||||
// 而 :memory: 下每个连接各自一个库,并发写入对后续读取不可见。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Cfg: cfg,
|
||||
Log: zap.NewNop(),
|
||||
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
|
||||
func loginHandlerSource(server string) string {
|
||||
loginJS := `function login(flag) {
|
||||
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
|
||||
var res = java.ajax(baseUrl + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
|
||||
var data = JSON.parse(res);
|
||||
if (data.code == 0) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
|
||||
java.toast(data.msg || '登录失败'); return false;
|
||||
}`
|
||||
ui, _ := json.Marshal([]map[string]any{
|
||||
{"name": "邮箱", "type": "text"},
|
||||
{"name": "密码", "type": "password"},
|
||||
{"name": "登录", "type": "button", "action": "login(true)"},
|
||||
})
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": server,
|
||||
"bookSourceName": "登录源",
|
||||
"bookSourceType": 0,
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": string(ui),
|
||||
"searchUrl": server + "/search",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(src)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// loginHandlerServer 模拟需要登录的站点。
|
||||
func loginHandlerServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login_api":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
|
||||
case "/search":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<html>未登录</html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
|
||||
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
|
||||
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
|
||||
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
|
||||
srv := loginHandlerServer(t)
|
||||
defer srv.Close()
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
// 导入书源
|
||||
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
|
||||
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())
|
||||
}
|
||||
|
||||
// 列表应标注 has_login
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书源列表 status=%d", w.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
HasLogin bool `json:"has_login"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
|
||||
t.Fatalf("书源列表解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !listResp.Sources[0].HasLogin {
|
||||
t.Fatal("has_login 应为 true")
|
||||
}
|
||||
sourceID := listResp.Sources[0].ID
|
||||
|
||||
// GET 登录信息:应返回表单字段与未登录状态
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var info struct {
|
||||
HasLoginJS bool `json:"has_login_js"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("登录信息解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !info.HasLoginJS || info.LoggedIn {
|
||||
t.Fatalf("初始状态异常: %+v", info)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range info.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, want := range []string{"邮箱", "密码", "登录"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("缺少表单字段 %q: %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码错误 → ok=false,提示透传
|
||||
loginBody, _ := json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
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())
|
||||
}
|
||||
var failRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Toasts []string `json:"toasts"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
|
||||
if failRes.LoggedIn {
|
||||
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
|
||||
t.Fatalf("失败提示未透传: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 正确密码 → ok=true 且已登录
|
||||
loginBody, _ = json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
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())
|
||||
}
|
||||
var okRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
|
||||
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 搜索应携带 Cookie 并拿到结果
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
var searchRes struct {
|
||||
Books []struct{ Name string } `json:"books"`
|
||||
Skipped []struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"skipped"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
|
||||
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
|
||||
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 登出 → 登录态清空
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登出 status=%d", w.Code)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
var afterLogout struct {
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
|
||||
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
|
||||
t.Fatalf("登出后应无登录态: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
|
||||
func TestReaderSetSourceVariableRoute(t *testing.T) {
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
|
||||
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)
|
||||
|
||||
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())
|
||||
}
|
||||
id := list.Sources[0].ID
|
||||
|
||||
// 合法 JSON
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
|
||||
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())
|
||||
}
|
||||
|
||||
// 非法 JSON 应 400
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"not-json"}`))
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。
|
||||
//
|
||||
// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库,
|
||||
// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。
|
||||
|
||||
// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1),
|
||||
// 便于在一个测试里切换用户验证隔离。
|
||||
func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
user := c.GetHeader("X-Test-User")
|
||||
if user == "" {
|
||||
user = "u1"
|
||||
}
|
||||
c.Set(middleware.CtxUserID, user)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func TestReaderProfileNullBeforeFirstSave(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
profile, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d", code)
|
||||
}
|
||||
// 没有记录时返回 null,由前端用它本地的值播种
|
||||
if profile != nil {
|
||||
t.Fatalf("新用户应返回 null,实际 %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileRoundTrip(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
body := `{
|
||||
"theme_id":"preset2","night":true,"page_mode":"scroll",
|
||||
"font_size":24,"line_height":2.1,"paragraph_spacing":12,
|
||||
"audio_speed":1.5,"audio_timer_minutes":30,
|
||||
"shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update",
|
||||
"shelf_show_unread":false,"shelf_show_update_time":true
|
||||
}`
|
||||
saved, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if saved == nil {
|
||||
t.Fatal("保存应返回偏好")
|
||||
}
|
||||
|
||||
got, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || got == nil {
|
||||
t.Fatalf("读回失败 status=%d profile=%+v", code, got)
|
||||
}
|
||||
if *got != *saved {
|
||||
t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got)
|
||||
}
|
||||
if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 ||
|
||||
got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 ||
|
||||
got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 ||
|
||||
got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime {
|
||||
t.Fatalf("字段未按原值往返: %+v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileClampsAndFallsBack(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
// 全部越界 / 枚举非法,服务端应收敛而不是原样入库
|
||||
body := `{
|
||||
"theme_id":" ","night":false,"page_mode":"nope",
|
||||
"font_size":999,"line_height":0.1,"paragraph_spacing":-5,
|
||||
"audio_speed":99,"audio_timer_minutes":9999,
|
||||
"shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random",
|
||||
"shelf_show_unread":true,"shelf_show_update_time":false
|
||||
}`
|
||||
got, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if got.ThemeID != "preset1" {
|
||||
t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID)
|
||||
}
|
||||
if got.PageMode != "page" {
|
||||
t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode)
|
||||
}
|
||||
if got.FontSize != 32 {
|
||||
t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize)
|
||||
}
|
||||
if got.LineHeight != 1.4 {
|
||||
t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight)
|
||||
}
|
||||
if got.ParagraphSpacing != 0 {
|
||||
t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing)
|
||||
}
|
||||
if got.AudioSpeed != 3 {
|
||||
t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed)
|
||||
}
|
||||
if got.AudioTimerMinutes != 180 {
|
||||
t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes)
|
||||
}
|
||||
if got.ShelfLayout != "grid" {
|
||||
t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout)
|
||||
}
|
||||
if got.ShelfGridColumns != 6 {
|
||||
t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns)
|
||||
}
|
||||
if got.ShelfSort != "recent" {
|
||||
t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort)
|
||||
}
|
||||
// 布尔值不参与收敛,必须原样保留
|
||||
if !got.ShelfShowUnread || got.ShelfShowUpdateTime {
|
||||
t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileIsPerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
if _, code := putProfile(t, router, "u1", `{
|
||||
"theme_id":"preset3","night":true,"page_mode":"page","font_size":18,
|
||||
"line_height":1.5,"paragraph_spacing":4,"audio_speed":1,
|
||||
"audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0,
|
||||
"shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录
|
||||
other, code := getProfile(t, router, "u2")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("u2 读取 status=%d", code)
|
||||
}
|
||||
if other != nil {
|
||||
t.Fatalf("u2 应返回 null,实际 %+v", other)
|
||||
}
|
||||
|
||||
if _, code := putProfile(t, router, "u2", `{
|
||||
"theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20,
|
||||
"line_height":1.8,"paragraph_spacing":8,"audio_speed":2,
|
||||
"audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3,
|
||||
"shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u2 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u1 的值不能被 u2 覆盖
|
||||
back, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || back == nil {
|
||||
t.Fatalf("u1 读回失败 status=%d", code)
|
||||
}
|
||||
if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night {
|
||||
t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8,
|
||||
"paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}`
|
||||
if _, code := putProfile(t, router, "u1", first); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0,
|
||||
"paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}`
|
||||
if _, code := putProfile(t, router, "u1", second); code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
|
||||
got, _ := getProfile(t, router, "u1")
|
||||
if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" {
|
||||
t.Fatalf("二次保存未覆盖旧值: %+v", got)
|
||||
}
|
||||
|
||||
// 一个用户只应有一条记录(唯一索引 + upsert)
|
||||
var count int64
|
||||
if err := svc.Repo.DB.Model(&model.ReaderProfile{}).
|
||||
Where("user_id = ?", "u1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("u1 的记录数 = %d,期望 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读进度接口的 HTTP 契约测试。
|
||||
//
|
||||
// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码,
|
||||
// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉
|
||||
// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。
|
||||
|
||||
func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsFractionalPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "有声书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 37.5 秒:音频进度上报的真实形态
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("书架应有 1 本,实际 %d", len(books))
|
||||
}
|
||||
// 小数秒截断成整数秒落库(dur_chapter_pos 是 int)
|
||||
if books[0].DurChapterPos != 37 {
|
||||
t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos)
|
||||
}
|
||||
if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" {
|
||||
t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsIntegerPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/2",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 文本/漫画仍是整数页码,不能被破坏
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 {
|
||||
t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressRejectsNegativePos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/3",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 负数不是合法进度,落库前应被夹到 0,而不是写入负值
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 0 {
|
||||
t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。
|
||||
func TestSaveProgressRoundTripsThroughJSON(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/4",
|
||||
}, "有声书", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 走列表接口(前端书架/阅读器恢复进度读的就是它)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Books []struct {
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
} `json:"books"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析书架响应失败: %v", err)
|
||||
}
|
||||
if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 {
|
||||
t.Fatalf("读回的进度 = %+v,期望 123", res.Books)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
// Package handler — 阅读(legado 书源兼容)子系统路由。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g := authed.Group("/reader")
|
||||
|
||||
// 书源管理
|
||||
g.GET("/sources", readerListSourcesHandler(svc))
|
||||
g.POST("/sources/import", readerImportSourcesHandler(svc))
|
||||
g.PATCH("/sources/:id", readerUpdateSourceHandler(svc))
|
||||
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
|
||||
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
|
||||
|
||||
// 书源登录与源变量(登录类书源必需)
|
||||
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
|
||||
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
|
||||
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
|
||||
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))
|
||||
|
||||
// 详情 / 目录 / 正文
|
||||
g.GET("/book-info", readerBookInfoHandler(svc))
|
||||
g.GET("/toc", readerTocHandler(svc))
|
||||
g.GET("/content", readerContentHandler(svc))
|
||||
|
||||
// 书架
|
||||
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.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
|
||||
|
||||
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
|
||||
g.GET("/profile", readerGetProfileHandler(svc))
|
||||
g.PUT("/profile", readerSaveProfileHandler(svc))
|
||||
|
||||
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
|
||||
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
|
||||
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
|
||||
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
|
||||
g.POST("/books/:id/origin", readerSwitchOriginHandler(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))
|
||||
|
||||
// 替换净化规则
|
||||
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
|
||||
g.POST("/replace-rules", readerCreateReplaceRuleHandler(svc))
|
||||
g.PATCH("/replace-rules/:id", readerUpdateReplaceRuleHandler(svc))
|
||||
g.DELETE("/replace-rules/:id", readerDeleteReplaceRuleHandler(svc))
|
||||
}
|
||||
|
||||
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sources, err := svc.Reader.ListSources(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"sources": sources})
|
||||
}
|
||||
}
|
||||
|
||||
func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"imported": imported})
|
||||
}
|
||||
}
|
||||
|
||||
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
|
||||
Action string `json:"action"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"books": books, "skipped": skipped})
|
||||
}
|
||||
}
|
||||
|
||||
func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info, err := svc.Reader.GetBookInfo(
|
||||
c.Request.Context(),
|
||||
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
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()})
|
||||
return
|
||||
}
|
||||
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||
// 「Cannot read properties of null」。空目录统一给 []。
|
||||
if chapters == nil {
|
||||
chapters = []reader.TocChapter{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
|
||||
func readerContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
content, err := svc.Reader.GetContent(
|
||||
c.Request.Context(),
|
||||
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
}
|
||||
|
||||
func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
books, err := svc.Reader.ListBooks(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"books": books})
|
||||
}
|
||||
}
|
||||
|
||||
func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Origin reader.SearchOrigin `json:"origin" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
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)
|
||||
book, err := svc.Reader.AddBook(c.Request.Context(), userID, body.Origin, body.Name, body.Author, body.CoverURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 后台补目录缓存,让书架能显示未读章数;失败不影响加入书架本身。
|
||||
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
|
||||
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
|
||||
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Origin reader.SearchOrigin `json:"origin" binding:"required"`
|
||||
}
|
||||
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)
|
||||
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
|
||||
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
|
||||
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.RemoveBook(c.Request.Context(), userID, c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerGetProfileHandler 读当前用户的阅读器偏好。
|
||||
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
|
||||
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": settings})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
|
||||
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReaderSettings
|
||||
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)
|
||||
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": saved})
|
||||
}
|
||||
}
|
||||
|
||||
// readerGetBookGroupsHandler 读当前用户的书架分组。
|
||||
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
|
||||
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
|
||||
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
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)
|
||||
groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
|
||||
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
|
||||
const maxReaderChapterPos = 10_000_000
|
||||
|
||||
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
|
||||
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
|
||||
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
|
||||
Pos float64 `json:"pos"`
|
||||
ChapterTitle string `json:"chapter_title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
|
||||
posFloat := body.Pos
|
||||
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
|
||||
posFloat = 0
|
||||
}
|
||||
if posFloat > maxReaderChapterPos {
|
||||
posFloat = maxReaderChapterPos
|
||||
}
|
||||
pos := int(posFloat)
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// 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"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
|
||||
func readerReplaceChaptersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Chapters []reader.ChapterInput `json:"chapters"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SaveChapters(c.Request.Context(), c.Param("id"), body.Chapters); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerListReplaceRulesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
rules, err := svc.Reader.ListReplaceRules(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rules": rules})
|
||||
}
|
||||
}
|
||||
|
||||
func readerCreateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReplaceRuleInput
|
||||
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)
|
||||
rule, err := svc.Reader.CreateReplaceRule(c.Request.Context(), userID, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func readerUpdateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReplaceRuleInput
|
||||
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.UpdateReplaceRule(c.Request.Context(), userID, c.Param("id"), body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.DeleteReplaceRule(c.Request.Context(), userID, c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerMediaProxyHandler 音频流/漫画图片签名代理:
|
||||
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
|
||||
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
|
||||
return
|
||||
}
|
||||
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
|
||||
return
|
||||
}
|
||||
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
|
||||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
|
||||
if isPlaylist {
|
||||
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
|
||||
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||
return
|
||||
}
|
||||
// 流式透传(含 206 Partial Content,支持音频拖动进度)
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter 参数需为整数"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,5 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
registerAuthedDLNAControlRoutes(authed, svc)
|
||||
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
|
||||
registerAuthedPlaybackExtraRoutes(authed, svc)
|
||||
registerReaderRoutes(authed, svc)
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package helper — AES-256-GCM at-rest secret encryption.
|
||||
//
|
||||
// Sensitive values (third-party API keys, service passwords, book-source
|
||||
// login credentials) are stored in the database. Encrypting them keyed off
|
||||
// a per-install secret means a stolen DB file alone is not enough to
|
||||
// recover the plaintext.
|
||||
//
|
||||
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag).
|
||||
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
|
||||
// install needs no migration step.
|
||||
package helper
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncPrefix tags ciphertext rows so they can be told apart from plaintext.
|
||||
const EncPrefix = "enc:v1:"
|
||||
|
||||
// SecretCipher encrypts/decrypts strings with AES-256-GCM derived from a
|
||||
// stable per-install secret. A zero-value SecretCipher (empty key) is a
|
||||
// pass-through, which keeps unit tests and keyless dev setups working.
|
||||
type SecretCipher struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// NewSecretCipher derives a 256-bit key from secret via SHA-256.
|
||||
func NewSecretCipher(secret string) *SecretCipher {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
return &SecretCipher{aead: aead}
|
||||
}
|
||||
|
||||
// Ready 表示是否真的能加解密(密钥有效)。
|
||||
func (c *SecretCipher) Ready() bool { return c != nil && c.aead != nil }
|
||||
|
||||
// Encrypt returns the prefixed base64 ciphertext for plain.
|
||||
// Empty inputs and already-encrypted values round-trip unchanged.
|
||||
func (c *SecretCipher) Encrypt(plain string) string {
|
||||
if plain == "" || !c.Ready() || strings.HasPrefix(plain, EncPrefix) {
|
||||
return plain
|
||||
}
|
||||
nonce := make([]byte, c.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return plain
|
||||
}
|
||||
sealed := c.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return EncPrefix + base64.StdEncoding.EncodeToString(sealed)
|
||||
}
|
||||
|
||||
// Decrypt returns the plaintext for a stored value. Plaintext rows (no
|
||||
// prefix) are returned unchanged, as are values that fail to authenticate.
|
||||
func (c *SecretCipher) Decrypt(value string) string {
|
||||
if value == "" || !c.Ready() || !strings.HasPrefix(value, EncPrefix) {
|
||||
return value
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, EncPrefix))
|
||||
if err != nil || len(data) < c.aead.NonceSize() {
|
||||
return value
|
||||
}
|
||||
nonce, body := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
|
||||
plain, err := c.aead.Open(nil, nonce, body, nil)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
|
||||
// IsEncrypted reports whether value carries the encrypted prefix.
|
||||
func (c *SecretCipher) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, EncPrefix)
|
||||
}
|
||||
@@ -61,5 +61,12 @@ func AllModels() []interface{} {
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
&ReaderBookSource{},
|
||||
&ReaderSourceState{},
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package model — 阅读(legado 书源兼容)子系统数据模型。
|
||||
// 字段语义对齐 legado 的 BookSource / Book / BookChapter / ReplaceRule 实体;
|
||||
// 阅读进度沿用 legado 的做法直接挂在书籍上(durChapter* 字段)。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
|
||||
type ReaderBookSource struct {
|
||||
Base
|
||||
Name string `gorm:"type:varchar(255);index" json:"name"`
|
||||
GroupName string `gorm:"type:varchar(255);index" json:"group"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
|
||||
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
|
||||
RawJSON string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
|
||||
CustomOrder int `json:"custom_order"`
|
||||
Weight int `json:"weight"`
|
||||
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
|
||||
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
// sourceVariable / userInfo(登录信息)/ loginHeader 与 CookieStore。
|
||||
// 与书源分表存放,避免每次导入更新书源时把用户登录态覆盖掉。
|
||||
type ReaderSourceState struct {
|
||||
Base
|
||||
SourceURL string `gorm:"type:varchar(512);uniqueIndex" json:"source_url"`
|
||||
Variable string `gorm:"type:text" json:"variable"` // source.getVariable/setVariable
|
||||
LoginInfo string `gorm:"type:text" json:"login_info"` // source.getLoginInfo/putLoginInfo(登录表单 JSON)
|
||||
LoginHeader string `gorm:"type:text" json:"login_header"` // source 登录请求头 JSON
|
||||
Cookies string `gorm:"type:text" json:"cookies"` // JSON: domain → "k=v; k=v"
|
||||
}
|
||||
|
||||
// ReaderBook 书架条目(含阅读进度,对应 legado Book)。
|
||||
type ReaderBook struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
|
||||
Origin string `gorm:"type:varchar(512)" json:"origin"` // 书源 URL
|
||||
OriginName string `gorm:"type:varchar(255)" json:"origin_name"`
|
||||
BookURL string `gorm:"type:varchar(512);index" json:"book_url"`
|
||||
TocURL string `gorm:"type:varchar(512)" json:"toc_url"`
|
||||
Name string `gorm:"type:varchar(255)" json:"name"`
|
||||
Author string `gorm:"type:varchar(255)" json:"author"`
|
||||
Kind string `gorm:"type:varchar(255)" json:"kind"`
|
||||
CoverURL string `gorm:"type:varchar(512)" json:"cover_url"`
|
||||
Intro string `gorm:"type:text" json:"intro"`
|
||||
Charset string `gorm:"type:varchar(32)" json:"charset"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
|
||||
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
|
||||
// LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
|
||||
// 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
|
||||
// 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
|
||||
LatestChapterTime int64 `json:"latest_chapter_time"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
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)。
|
||||
type ReaderChapter struct {
|
||||
Base
|
||||
BookID string `gorm:"type:varchar(36);uniqueIndex:idx_reader_book_chapter" json:"book_id"`
|
||||
Index int `gorm:"uniqueIndex:idx_reader_book_chapter" json:"index"`
|
||||
URL string `gorm:"type:varchar(512)" json:"url"`
|
||||
Title string `gorm:"type:varchar(512)" json:"title"`
|
||||
IsVolume bool `json:"is_volume"`
|
||||
Tag string `gorm:"type:varchar(255)" json:"tag"`
|
||||
}
|
||||
|
||||
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
|
||||
type ReaderReplaceRule struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
|
||||
Name string `gorm:"type:varchar(255)" json:"name"`
|
||||
GroupName string `gorm:"type:varchar(255);index" json:"group"`
|
||||
Pattern string `gorm:"type:text" json:"pattern"`
|
||||
Replacement string `gorm:"type:text" json:"replacement"`
|
||||
Scope string `gorm:"type:varchar(255)" json:"scope"`
|
||||
ScopeTitle bool `json:"scope_title"`
|
||||
ScopeContent bool `gorm:"default:true" json:"scope_content"`
|
||||
ExcludeScope string `gorm:"type:varchar(255)" json:"exclude_scope"`
|
||||
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
|
||||
IsRegex bool `gorm:"default:true" json:"is_regex"`
|
||||
TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// ReaderProfile 阅读器偏好(每个用户一条)。
|
||||
//
|
||||
// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级:
|
||||
// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。
|
||||
// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。
|
||||
type ReaderProfile struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
|
||||
ThemeID string `gorm:"type:varchar(32)" json:"theme_id"`
|
||||
Night bool `json:"night"`
|
||||
PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动
|
||||
FontSize int `json:"font_size"`
|
||||
LineHeight float64 `json:"line_height"`
|
||||
ParagraphSpacing int `json:"paragraph_spacing"`
|
||||
|
||||
// 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer)
|
||||
AudioSpeed float64 `json:"audio_speed"`
|
||||
AudioTimerMinutes int `json:"audio_timer_minutes"`
|
||||
|
||||
// 书架展示偏好(对应 legado 的书架设置)
|
||||
ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact
|
||||
ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应
|
||||
ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"`
|
||||
ShelfShowUnread bool `json:"shelf_show_unread"`
|
||||
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 书架分组(对应 legado 的 BookGroup)。
|
||||
//
|
||||
// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。
|
||||
// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。
|
||||
|
||||
const (
|
||||
// MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。
|
||||
MaxBookGroups = 50
|
||||
// MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。
|
||||
MaxBookGroupNameLen = 24
|
||||
)
|
||||
|
||||
// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。
|
||||
type BookGroupSet struct {
|
||||
Name string `json:"name"`
|
||||
BookIDs []string `json:"book_ids"`
|
||||
}
|
||||
|
||||
// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。
|
||||
//
|
||||
// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON;
|
||||
// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰:
|
||||
// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。
|
||||
type ReaderBookGroups struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
// Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。
|
||||
Groups string `gorm:"type:text" json:"-"`
|
||||
}
|
||||
|
||||
// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。
|
||||
func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet {
|
||||
if r == nil || strings.TrimSpace(r.Groups) == "" {
|
||||
return nil
|
||||
}
|
||||
var groups []BookGroupSet
|
||||
if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NormalizeBookGroups(groups)
|
||||
}
|
||||
|
||||
// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。
|
||||
// 空集合序列化为空字符串,便于用零值表达「没有分组」。
|
||||
func EncodeBookGroups(groups []BookGroupSet) (string, error) {
|
||||
if len(groups) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的
|
||||
// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道
|
||||
// 书籍是否存在,属于服务层(SetBookGroups)的职责。
|
||||
func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]BookGroupSet, 0, len(groups))
|
||||
indexByName := make(map[string]int, len(groups))
|
||||
for _, group := range groups {
|
||||
name := TruncateBookGroupName(group.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxBookGroups {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
pos, exists := indexByName[key]
|
||||
if !exists {
|
||||
out = append(out, BookGroupSet{Name: name, BookIDs: []string{}})
|
||||
pos = len(out) - 1
|
||||
indexByName[key] = pos
|
||||
}
|
||||
seen := make(map[string]struct{}, len(out[pos].BookIDs))
|
||||
for _, id := range out[pos].BookIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range group.BookIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out[pos].BookIDs = append(out[pos].BookIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].BookIDs == nil {
|
||||
out[i].BookIDs = []string{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。
|
||||
func TruncateBookGroupName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxBookGroupNameLen {
|
||||
runes = runes[:MaxBookGroupNameLen]
|
||||
}
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。
|
||||
|
||||
func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) {
|
||||
groups := []BookGroupSet{
|
||||
{Name: " 科幻 ", BookIDs: []string{"a", "b"}},
|
||||
{Name: "科幻", BookIDs: []string{"b", "c"}},
|
||||
{Name: " ", BookIDs: []string{"d"}},
|
||||
{Name: "在读", BookIDs: []string{"", "e", "e"}},
|
||||
}
|
||||
got := NormalizeBookGroups(groups)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" {
|
||||
t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0])
|
||||
}
|
||||
if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" {
|
||||
t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) {
|
||||
long := strings.Repeat("长", MaxBookGroupNameLen+10)
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: long}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen {
|
||||
t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen)
|
||||
}
|
||||
|
||||
many := make([]BookGroupSet, 0, MaxBookGroups+5)
|
||||
for i := 0; i < MaxBookGroups+5; i++ {
|
||||
many = append(many, BookGroupSet{Name: string(rune('a' + i))})
|
||||
}
|
||||
if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups {
|
||||
t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) {
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸
|
||||
if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 {
|
||||
t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) {
|
||||
if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" {
|
||||
t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err)
|
||||
}
|
||||
|
||||
raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeBookGroups: %v", err)
|
||||
}
|
||||
row := &ReaderBookGroups{Groups: raw}
|
||||
decoded := row.DecodeBookGroups()
|
||||
if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" {
|
||||
t.Fatalf("DecodeBookGroups = %#v", decoded)
|
||||
}
|
||||
|
||||
row.Groups = "{not json"
|
||||
if decoded := row.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
|
||||
// nil 接收者不应 panic(仓库层查不到记录时会返回 nil)
|
||||
var nilRow *ReaderBookGroups
|
||||
if decoded := nilRow.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package repository — 阅读子系统仓储(书源 / 书架 / 章节 / 替换规则)。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// ReaderRepository 阅读子系统 GORM 封装。
|
||||
type ReaderRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// ListSources 书源列表(按 customOrder 排序)。
|
||||
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
|
||||
var out []model.ReaderBookSource
|
||||
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetSource 按 ID 取书源。
|
||||
func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.ReaderBookSource, error) {
|
||||
var out model.ReaderBookSource
|
||||
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetSourceByURL 按书源 URL 取书源(导入去重用)。
|
||||
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
|
||||
var out model.ReaderBookSource
|
||||
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateSource 新增书源。
|
||||
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
|
||||
return r.db.WithContext(ctx).Create(src).Error
|
||||
}
|
||||
|
||||
// UpdateSource 更新书源字段。
|
||||
func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBookSource) error {
|
||||
return r.db.WithContext(ctx).Save(src).Error
|
||||
}
|
||||
|
||||
// DeleteSource 删除书源(连带清理其会话状态)。
|
||||
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
src := &model.ReaderBookSource{}
|
||||
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
|
||||
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
|
||||
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
|
||||
var out model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
// ListBooks 用户书架(按 order 排序)。
|
||||
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
|
||||
var out []model.ReaderBook
|
||||
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetBook 按 ID 取书。
|
||||
func (r *ReaderRepository) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
|
||||
var out model.ReaderBook
|
||||
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// FindBookByURL 按用户 + 书源 + 书 URL 查书(加书架去重)。
|
||||
func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bookURL string) (*model.ReaderBook, error) {
|
||||
var out model.ReaderBook
|
||||
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND origin = ? AND book_url = ?", userID, origin, bookURL).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateBook / UpdateBook / DeleteBook。
|
||||
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
|
||||
return r.db.WithContext(ctx).Create(b).Error
|
||||
}
|
||||
|
||||
func (r *ReaderRepository) UpdateBook(ctx context.Context, b *model.ReaderBook) error {
|
||||
return r.db.WithContext(ctx).Save(b).Error
|
||||
}
|
||||
|
||||
func (r *ReaderRepository) DeleteBook(ctx context.Context, userID, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&model.ReaderBook{}, "id = ? AND user_id = ?", id, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.ReaderChapter{}, "book_id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 章节是纯缓存(软删会留下行,撞上 (book_id, index) 唯一索引),这里物理删除
|
||||
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.CreateInBatches(chapters, 500).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListChapters 按序取章节。
|
||||
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
|
||||
var out []model.ReaderChapter
|
||||
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error
|
||||
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
|
||||
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil),
|
||||
// 由调用方决定是否用前端上送的当前值播种。
|
||||
func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) {
|
||||
var out model.ReaderProfile
|
||||
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error {
|
||||
var existing model.ReaderProfile
|
||||
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = existing.ID
|
||||
p.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(p).Error
|
||||
}
|
||||
|
||||
// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。
|
||||
func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) {
|
||||
var out model.ReaderBookGroups
|
||||
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error {
|
||||
var existing model.ReaderBookGroups
|
||||
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.ID = existing.ID
|
||||
row.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
// ListReplaceRules 用户替换规则(按 order 排序)。
|
||||
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
|
||||
var out []model.ReaderReplaceRule
|
||||
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateReplaceRule / UpdateReplaceRule / DeleteReplaceRule。
|
||||
func (r *ReaderRepository) CreateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
|
||||
return r.db.WithContext(ctx).Create(rule).Error
|
||||
}
|
||||
|
||||
func (r *ReaderRepository) UpdateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
|
||||
return r.db.WithContext(ctx).Save(rule).Error
|
||||
}
|
||||
|
||||
func (r *ReaderRepository) DeleteReplaceRule(ctx context.Context, userID, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ReaderReplaceRule{}, "id = ? AND user_id = ?", id, userID).Error
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func newReaderTestRepo(t *testing.T) *ReaderRepository {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return New(db).Reader
|
||||
}
|
||||
|
||||
// TestSourceStateRoundTrip 验证会话状态按书源 URL 存取。
|
||||
func TestSourceStateRoundTrip(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 不存在时返回 (nil, nil),调用方据此走默认值
|
||||
got, err := repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("首次读取应为空: got=%v err=%v", got, err)
|
||||
}
|
||||
|
||||
st := &model.ReaderSourceState{
|
||||
SourceURL: "https://a.example.com",
|
||||
Variable: `{"线路":"v1"}`,
|
||||
LoginInfo: `{"邮箱":"u@e.com"}`,
|
||||
LoginHeader: `{"X-Token":"t"}`,
|
||||
Cookies: `{"example.com":"qttoken=abc"}`,
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LoginInfo != st.LoginInfo || got.Variable != st.Variable {
|
||||
t.Fatalf("读回不一致: %+v", got)
|
||||
}
|
||||
|
||||
// 再次保存应为更新而非插入(同 URL 唯一)
|
||||
st.LoginInfo = `{"邮箱":"new@e.com"}`
|
||||
if err := repo.SaveSourceState(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := repo.db.Model(&model.ReaderSourceState{}).
|
||||
Where("source_url = ?", "https://a.example.com").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("同一书源应只有一行状态,实际 %d", count)
|
||||
}
|
||||
got, _ = repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if got.LoginInfo != `{"邮箱":"new@e.com"}` {
|
||||
t.Fatalf("更新未生效: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteSourceAlsoClearsState 删除书源应连带清理登录态。
|
||||
func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://b.example.com", Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: "https://b.example.com", LoginInfo: `{"a":"b"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, "https://b.example.com")
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st != nil {
|
||||
t.Fatalf("书源删除后登录态应一并清理,实际仍在: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://c.example.com", RawJSON: `{"v":1}`}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: "https://c.example.com", LoginInfo: `{"k":"keep"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟导入更新:只改书源自身字段
|
||||
src.RawJSON = `{"v":2}`
|
||||
if err := repo.UpdateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, "https://c.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.LoginInfo != `{"k":"keep"}` {
|
||||
t.Fatalf("更新书源后登录态丢失: %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Container struct {
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
ScrapeTask *ScrapeTaskRepository
|
||||
EmbyMount *EmbyMountRepository
|
||||
Reader *ReaderRepository
|
||||
}
|
||||
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
@@ -68,5 +69,6 @@ func New(db *gorm.DB) *Container {
|
||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||
EmbyMount: &EmbyMountRepository{db: db},
|
||||
Reader: &ReaderRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,16 +128,16 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
|
||||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
|
||||
|
||||
if !isPlaylist {
|
||||
// Content-Type 缺失时用前 4KB 内容嗅探是否为播放列表;无论结果如何
|
||||
// 都要把已读前缀接回 body,避免分片/播放列表丢开头字节。
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
|
||||
isPlaylist = true
|
||||
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
|
||||
} else {
|
||||
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
|
||||
}
|
||||
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
|
||||
}
|
||||
|
||||
copyUpstreamHeaders(w, resp, isPlaylist)
|
||||
@@ -195,9 +195,17 @@ func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
|
||||
}
|
||||
}
|
||||
if len(p.sessions) >= cloud115HLSMaxSessions {
|
||||
for id := range p.sessions {
|
||||
delete(p.sessions, id)
|
||||
break
|
||||
// ExpiresAt 恒为「最近一次活跃时间 + TTL」(lookup 会滑动续期),
|
||||
// 因此最小者即最久未活跃的会话,淘汰它对正在播放的影响最小。
|
||||
evictID := ""
|
||||
var evictAt time.Time
|
||||
for id, existing := range p.sessions {
|
||||
if evictID == "" || existing.ExpiresAt.Before(evictAt) {
|
||||
evictID, evictAt = id, existing.ExpiresAt
|
||||
}
|
||||
}
|
||||
if evictID != "" {
|
||||
delete(p.sessions, evictID)
|
||||
}
|
||||
}
|
||||
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
|
||||
@@ -210,9 +218,15 @@ func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, s
|
||||
}
|
||||
p.mu.Lock()
|
||||
session := p.sessions[sessionID]
|
||||
if session != nil && time.Now().After(session.ExpiresAt) {
|
||||
delete(p.sessions, sessionID)
|
||||
session = nil
|
||||
if session != nil {
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
delete(p.sessions, sessionID)
|
||||
session = nil
|
||||
} else {
|
||||
// 滑动续期:VOD 点播整个播放期间只有分片请求,master 只在起播时取
|
||||
// 一次,不续期的话会话会在 TTL 后被删,长视频播放到一半必然断流。
|
||||
session.ExpiresAt = time.Now().Add(cloud115HLSSessionTTL)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if session == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -56,6 +57,67 @@ https://cpats01.115.com/seg.ts?x=1
|
||||
}
|
||||
}
|
||||
|
||||
// 长视频 VOD 播放期间只有分片请求经过 lookup,会话必须随活动滑动续期,
|
||||
// 否则固定 30 分钟 TTL 一到就会在中途 404 断流。
|
||||
func TestCloud115HLSProxyLookupRenewsSession(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
|
||||
}
|
||||
proxy.storeSession(session)
|
||||
// 模拟播放进行到第 29 分钟:距过期只剩 1 分钟。
|
||||
session.ExpiresAt = time.Now().Add(time.Minute)
|
||||
|
||||
if _, _, ok := proxy.lookup(session.ID, "e1"); !ok {
|
||||
t.Fatal("active session should still resolve before expiry")
|
||||
}
|
||||
if remaining := time.Until(session.ExpiresAt); remaining < cloud115HLSSessionTTL-10*time.Second {
|
||||
t.Fatalf("session not renewed on activity, remaining = %v", remaining)
|
||||
}
|
||||
|
||||
// 真正过期(期间无任何活动)的会话仍要被清理。
|
||||
session.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if _, _, ok := proxy.lookup(session.ID, "e1"); ok {
|
||||
t.Fatal("expired session should not resolve")
|
||||
}
|
||||
proxy.mu.Lock()
|
||||
_, exists := proxy.sessions[session.ID]
|
||||
proxy.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("expired session should be evicted from the map")
|
||||
}
|
||||
}
|
||||
|
||||
// 满员驱逐必须淘汰最久未活跃的会话(ExpiresAt = 最近活跃 + TTL,最小者即
|
||||
// 最久未活跃),而不是随机挑一个——随机驱逐可能正好杀掉正在播放的会话。
|
||||
func TestCloud115HLSProxyEvictsLeastRecentlyActiveSession(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
first := &cloud115HLSSession{ID: "active", MediaID: "media-1", entries: map[string]string{}}
|
||||
proxy.storeSession(first)
|
||||
for i := 0; i < cloud115HLSMaxSessions-1; i++ {
|
||||
proxy.storeSession(&cloud115HLSSession{ID: fmt.Sprintf("s-%03d", i), MediaID: "media-1", entries: map[string]string{}})
|
||||
}
|
||||
// 人为拉开活跃时间差,避免依赖时钟精度:active 最近活跃,s-000 最久未活跃。
|
||||
base := time.Now()
|
||||
first.ExpiresAt = base.Add(cloud115HLSSessionTTL)
|
||||
proxy.sessions["s-000"].ExpiresAt = base.Add(cloud115HLSSessionTTL - time.Hour)
|
||||
|
||||
proxy.storeSession(&cloud115HLSSession{ID: "newcomer", MediaID: "media-1", entries: map[string]string{}})
|
||||
|
||||
proxy.mu.Lock()
|
||||
_, activeExists := proxy.sessions["active"]
|
||||
_, staleExists := proxy.sessions["s-000"]
|
||||
proxy.mu.Unlock()
|
||||
if !activeExists {
|
||||
t.Fatal("recently active session must survive eviction")
|
||||
}
|
||||
if staleExists {
|
||||
t.Fatal("least recently active session should have been evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowed115UpstreamHost(t *testing.T) {
|
||||
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
|
||||
if !isAllowed115UpstreamHost(host) {
|
||||
|
||||
@@ -220,6 +220,11 @@ func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaI
|
||||
return strings.TrimSpace(item.URL), data, nil
|
||||
}
|
||||
|
||||
// cloud115PushStateRetention pushState 条目保留上限。成功条目 3 小时内用于
|
||||
// 去重;超过一倍余量即为死数据,触发转码请求时顺带清理,防止 map 随观看过的
|
||||
// 条目数无限增长。
|
||||
const cloud115PushStateRetention = 6 * time.Hour
|
||||
|
||||
func (s *Cloud115PlaybackService) ensureCloudTranscode(
|
||||
ctx context.Context,
|
||||
accountID, pickCode string,
|
||||
@@ -230,6 +235,13 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
if len(s.pushState) > 0 {
|
||||
for k, v := range s.pushState {
|
||||
if now.Sub(v.at) > cloud115PushStateRetention {
|
||||
delete(s.pushState, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if last, ok := s.pushState[key]; ok {
|
||||
ttl := 2 * time.Minute
|
||||
if last.success {
|
||||
|
||||
+15
-59
@@ -12,96 +12,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
// encPrefix tags ciphertext rows so we can tell them apart from legacy
|
||||
// plaintext values.
|
||||
const encPrefix = "enc:v1:"
|
||||
// plaintext values. Kept as an alias of the shared helper's prefix so both
|
||||
// implementations stay wire-compatible.
|
||||
const encPrefix = helper.EncPrefix
|
||||
|
||||
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
|
||||
// secret (the JWT secret).
|
||||
//
|
||||
// The cipher itself lives in helper.SecretCipher so lower layers (e.g. the
|
||||
// reader subsystem, which cannot import this package) can share one
|
||||
// implementation; this type keeps the service-layer logging and API.
|
||||
type CryptoService struct {
|
||||
log *zap.Logger
|
||||
aead cipher.AEAD
|
||||
log *zap.Logger
|
||||
cipher *helper.SecretCipher
|
||||
}
|
||||
|
||||
// NewCryptoService derives a 256-bit key from the given secret via
|
||||
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
|
||||
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
|
||||
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
|
||||
c := &CryptoService{log: log}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return c
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
log.Error("crypto: aes.NewCipher", zap.Error(err))
|
||||
return c
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
log.Error("crypto: cipher.NewGCM", zap.Error(err))
|
||||
return c
|
||||
}
|
||||
c.aead = aead
|
||||
return c
|
||||
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
|
||||
}
|
||||
|
||||
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
|
||||
// Empty inputs round-trip unchanged.
|
||||
func (c *CryptoService) Encrypt(plain string) string {
|
||||
if plain == "" || c.aead == nil {
|
||||
return plain
|
||||
}
|
||||
if strings.HasPrefix(plain, encPrefix) {
|
||||
return plain
|
||||
}
|
||||
nonce := make([]byte, c.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return plain
|
||||
}
|
||||
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
|
||||
return c.cipher.Encrypt(plain)
|
||||
}
|
||||
|
||||
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
||||
// (no prefix) are returned unchanged.
|
||||
func (c *CryptoService) Decrypt(value string) string {
|
||||
if c == nil || value == "" || c.aead == nil {
|
||||
return value
|
||||
}
|
||||
if !strings.HasPrefix(value, encPrefix) {
|
||||
return value
|
||||
}
|
||||
raw := strings.TrimPrefix(value, encPrefix)
|
||||
data, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
if len(data) < c.aead.NonceSize() {
|
||||
return value
|
||||
}
|
||||
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
|
||||
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return string(plain)
|
||||
return c.cipher.Decrypt(value)
|
||||
}
|
||||
|
||||
// IsEncrypted returns true if value carries the encrypted prefix.
|
||||
func (c *CryptoService) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, encPrefix)
|
||||
return c.cipher.IsEncrypted(value)
|
||||
}
|
||||
|
||||
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
|
||||
|
||||
@@ -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,372 @@
|
||||
// 本地/远端有声书的按需转码:源文件是浏览器解不了的格式(典型是 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
|
||||
}
|
||||
// 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法,
|
||||
// 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。
|
||||
if headers["Referer"] == "" {
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
headers["Referer"] = referer
|
||||
}
|
||||
}
|
||||
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,107 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 HTML。
|
||||
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。
|
||||
func TestImageRefsInLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}},
|
||||
{"双引号 img", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
|
||||
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
|
||||
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
|
||||
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
|
||||
{"img 没有 src 时跳过", `<img alt="空">`, nil},
|
||||
{"非 img 的 HTML 片段跳过", `</div>`, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := imageRefsInLine(c.line)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Fatalf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址,
|
||||
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
|
||||
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
page := `<html><body><div class="pages">
|
||||
<img src="/img/1.webp"><img src="/img/2.webp">
|
||||
</div></body></html>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "漫画测试源",
|
||||
"bookSourceType": 64,
|
||||
"ruleContent": { "content": "class.pages@html" }
|
||||
}`, srv.URL)
|
||||
sourceID := importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "漫画测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "航海王",
|
||||
Type: 2, // 图片
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
_ = sourceID
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "image" {
|
||||
t.Fatalf("类型 = %q,期望 image", out.Type)
|
||||
}
|
||||
if len(out.Images) != 2 {
|
||||
t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
// 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头),
|
||||
// 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。
|
||||
if !strings.HasPrefix(img, "/api/reader/media?") {
|
||||
t.Fatalf("第 %d 张不是代理地址:%q", i, img)
|
||||
}
|
||||
encoded := img[strings.Index(img, "u=")+2:]
|
||||
if end := strings.IndexByte(encoded, '&'); end >= 0 {
|
||||
encoded = encoded[:end]
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 张地址解不开:%q", i, img)
|
||||
}
|
||||
want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1)
|
||||
if string(raw) != want {
|
||||
t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法:
|
||||
//
|
||||
// $..url
|
||||
// <js>result.split("\n").map(x=>'<img src="'+x+'">').join("\n")</js>
|
||||
//
|
||||
// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串
|
||||
// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则),
|
||||
// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。
|
||||
func TestContentRuleJSReceivesJoinedString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
const srcURL = "https://www.mangacopy.com/"
|
||||
const contentRule = "$..url\n<js>result.split(\"\\n\").map(x=>'<img src=\"'+x+'\">').join(\"\\n\")</js>"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srcURL,
|
||||
RuleContent: &ContentRule{Content: strPtr(contentRule)},
|
||||
Header: &header,
|
||||
}
|
||||
|
||||
body := `{"results":{"chapter":{"contents":[` +
|
||||
`{"url":"https://img.example.com/1.webp"},` +
|
||||
`{"url":"https://img.example.com/2.webp"}]}}}`
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x")
|
||||
|
||||
got, err := ar.GetString(contentRule, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("正文规则求值失败: %v", err)
|
||||
}
|
||||
want := "<img src=\"https://img.example.com/1.webp\">\n<img src=\"https://img.example.com/2.webp\">"
|
||||
if got != want {
|
||||
t.Fatalf("正文 = %q\n期望 %q", got, want)
|
||||
}
|
||||
// 图片提取(漫画分支):每行一个 <img>,应抽出 2 个真实地址
|
||||
refs := 0
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
refs += len(imageRefsInLine(line))
|
||||
}
|
||||
if refs != 2 {
|
||||
t.Fatalf("从正文抽出 %d 张图,期望 2", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回,
|
||||
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
|
||||
func TestNormalizeCoverURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"blank", " ", ""},
|
||||
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
|
||||
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
|
||||
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
|
||||
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
|
||||
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
|
||||
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
|
||||
{
|
||||
"search params envelope",
|
||||
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
|
||||
"",
|
||||
},
|
||||
{"text data uri", "data:text/plain;base64,aGk=", ""},
|
||||
{"source name", "光遇聚合", ""},
|
||||
{"javascript url", "javascript:alert(1)", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeCoverURL(tc.in); got != tc.want {
|
||||
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package reader
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
func base64EncodeStr(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 书架分组(对应 legado BookGroup;存储见 model.ReaderBookGroups)。
|
||||
//
|
||||
// 语义与影视模块的媒体库标签一致:整份替换、一本书只归一个组、组内顺序即展示顺序。
|
||||
// 额外的两条收敛放在服务层,因为它们需要知道书籍是否存在:
|
||||
// - 只保留该用户书架上的书(书被移出书架后分组里不留死 ID);
|
||||
// - 一本书只归一个组(越靠前的分组优先)。
|
||||
// 空分组会保留:用户可能就是先建好分组再往里放书。
|
||||
|
||||
// GetBookGroups 读用户的书架分组。没有分组时返回空切片(非 nil),方便前端直接遍历。
|
||||
func (s *ReaderService) GetBookGroups(ctx context.Context, userID string) ([]model.BookGroupSet, error) {
|
||||
if userID == "" {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
row, err := s.repo.GetBookGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := model.NormalizeBookGroups(row.DecodeBookGroups())
|
||||
if len(groups) == 0 {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
owned, err := s.ownedBookIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterBookGroups(groups, owned), nil
|
||||
}
|
||||
|
||||
// SetBookGroups 覆盖保存用户的书架分组,落库前做过收敛与归属过滤。
|
||||
func (s *ReaderService) SetBookGroups(ctx context.Context, userID string, groups []model.BookGroupSet) ([]model.BookGroupSet, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("缺少用户信息")
|
||||
}
|
||||
owned, err := s.ownedBookIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := dedupeBookGroups(filterBookGroups(model.NormalizeBookGroups(groups), owned))
|
||||
raw, err := model.EncodeBookGroups(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.SaveBookGroups(ctx, &model.ReaderBookGroups{UserID: userID, Groups: raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// ownedBookIDs 返回该用户书架上的书籍 ID 集合。
|
||||
func (s *ReaderService) ownedBookIDs(ctx context.Context, userID string) (map[string]struct{}, error) {
|
||||
books, err := s.repo.ListBooks(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]struct{}, len(books))
|
||||
for i := range books {
|
||||
out[books[i].ID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// filterBookGroups 只保留 owned 里的书籍 ID;空分组原样保留。
|
||||
func filterBookGroups(groups []model.BookGroupSet, owned map[string]struct{}) []model.BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.BookGroupSet, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
ids := make([]string, 0, len(group.BookIDs))
|
||||
for _, id := range group.BookIDs {
|
||||
if _, ok := owned[id]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeBookGroups 收敛成「一本书只归一个组」:越靠前的分组优先,后面的重复项被移除。
|
||||
func dedupeBookGroups(groups []model.BookGroupSet) []model.BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
claimed := make(map[string]struct{})
|
||||
out := make([]model.BookGroupSet, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
ids := make([]string, 0, len(group.BookIDs))
|
||||
for _, id := range group.BookIDs {
|
||||
if _, ok := claimed[id]; ok {
|
||||
continue
|
||||
}
|
||||
claimed[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:书源登录(对应 legado SourceLoginDialog / SourceLoginViewModel)。
|
||||
//
|
||||
// legado 的登录模型:
|
||||
// - loginUrl 是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
|
||||
// - loginUi 是一份表单描述(RowUi 数组:text / password / button / toggle / select);
|
||||
// - 点按钮时执行 "loginUrl + '\n' + action",作用域里 result 是"表单当前值",
|
||||
// 返回值被丢弃,只有抛异常才算失败;
|
||||
// - 点"登录"确认按钮时执行 loginUrl 里的 login() 函数(不存在则报错)。
|
||||
//
|
||||
// 服务端无法弹窗,因此把 java.toast / java.startBrowser 的调用收集起来回传前端。
|
||||
|
||||
// LoginField 登录表单的一个控件(对应 legado RowUi)。
|
||||
type LoginField struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // text / password / button / toggle / select
|
||||
Action string `json:"action,omitempty"`
|
||||
Chars []string `json:"chars,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
// ViewName 按钮/标签文案;legado 中它本身可以是 JS 表达式。
|
||||
ViewName string `json:"viewName,omitempty"`
|
||||
Style map[string]any `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// SourceLoginInfo 书源登录界面描述与当前状态。
|
||||
type SourceLoginInfo struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
// HasLoginJS loginUrl 提供了登录逻辑。
|
||||
HasLoginJS bool `json:"has_login_js"`
|
||||
// LoginJS 登录逻辑 JS 原文(前端只作展示/调试,不执行)。
|
||||
LoginJS string `json:"login_js,omitempty"`
|
||||
// Fields 登录表单控件。
|
||||
Fields []LoginField `json:"fields"`
|
||||
// Values 已保存的登录信息(表单回填)。
|
||||
Values map[string]string `json:"values"`
|
||||
// Cookies 当前已保存的 Cookie(domain → cookie 串)。
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
// Variable 源变量 JSON 原文(供变量编辑器)。
|
||||
Variable string `json:"variable"`
|
||||
// VariableComment 源变量说明(书源作者写的填写指引)。
|
||||
VariableComment string `json:"variable_comment,omitempty"`
|
||||
// LoggedIn 是否已具备登录态(存了登录信息或 Cookie)。
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
// LoginFieldsHint loginUi 解析或执行的失败原因(不阻断展示)。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResult 登录动作执行结果。
|
||||
type LoginResult struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// GetSourceLogin 返回书源登录界面描述与当前登录状态。
|
||||
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
|
||||
|
||||
info := &SourceLoginInfo{
|
||||
SourceID: src.ID,
|
||||
SourceName: firstNonEmpty(src.Name, bs.BookSourceName),
|
||||
HasLoginJS: strings.TrimSpace(SPtr(bs.LoginURL)) != "",
|
||||
LoginJS: bs.LoginJS(),
|
||||
Fields: []LoginField{},
|
||||
Values: map[string]string{},
|
||||
Cookies: state.snapshotCookies(),
|
||||
Variable: state.GetVariable(),
|
||||
VariableComment: SPtr(bs.VariableComment),
|
||||
}
|
||||
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
|
||||
info.Values = m
|
||||
}
|
||||
// 登录态以「是否拿到凭证(Cookie)」为准:
|
||||
// 只填过表单并不等于已登录,否则失败的登录也会显示为已登录。
|
||||
info.LoggedIn = len(info.Cookies) > 0
|
||||
|
||||
fields, err := s.resolveLoginFields(sess, bs, info.Values)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
// loginUi 解析失败时若只有 loginUrl,仍可用「打开登录页」方式登录。
|
||||
if SPtr(bs.LoginUI) == "" {
|
||||
info.Error = ""
|
||||
}
|
||||
} else {
|
||||
info.Fields = fields
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// resolveLoginFields 解析 loginUi:直接是 JSON 数组时直接解析;
|
||||
// 是 @js:/<js> 时先执行得到 JSON(对应 legado evalUiJs)。
|
||||
// values 为已保存的登录信息,作为 result 注入(legado 的 loginUi JS 会读它)。
|
||||
func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource, values map[string]string) ([]LoginField, error) {
|
||||
raw := strings.TrimSpace(SPtr(bs.LoginUI))
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("书源未配置登录界面(loginUi)")
|
||||
}
|
||||
if isJSWrapped(raw) {
|
||||
runner := sess.runner("", 0)
|
||||
v, err := runner.EvalAction(bs.LoginJS()+"\n"+stripJSWrapper(raw), loginBindings(sess, values))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loginUi JS 执行失败: %w", err)
|
||||
}
|
||||
raw = strings.TrimSpace(anyToStr(v))
|
||||
}
|
||||
var fields []LoginField
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return nil, fmt.Errorf("loginUi 不是合法的 JSON 表单: %w", err)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// RunLoginAction 执行一个登录动作。
|
||||
//
|
||||
// action 为 loginUi 里某个控件的 action(如 "login(true)" / "checkStatus()");
|
||||
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
|
||||
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
|
||||
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
|
||||
|
||||
loginJS := bs.LoginJS()
|
||||
if loginJS == "" {
|
||||
return nil, fmt.Errorf("书源未配置登录逻辑(loginUrl)")
|
||||
}
|
||||
|
||||
// 合并登录信息:已保存值 + 本次提交值(对应 legado getLoginData)。
|
||||
values := map[string]string{}
|
||||
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
|
||||
values = m
|
||||
}
|
||||
for k, v := range fields {
|
||||
values[k] = v
|
||||
}
|
||||
// 若提供了新的表单值,先持久化(legado 在调用登录函数前先存 loginInfo)。
|
||||
if len(fields) > 0 {
|
||||
if b, err := json.Marshal(values); err == nil {
|
||||
state.SetLoginInfo(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
body := loginJS + "\n"
|
||||
if strings.TrimSpace(action) == "" {
|
||||
// 对应 legado:login() 必须由书源实现
|
||||
body += "if (typeof login=='function'){ login.apply(this); } else { throw('Function login not implements!!!'); }"
|
||||
} else {
|
||||
body += action
|
||||
}
|
||||
|
||||
runner := sess.runner("", 0)
|
||||
_, runErr := runner.EvalAction(body, loginBindings(sess, values))
|
||||
|
||||
// 无论成功失败都要落库:Cookie/变量可能已被部分改写(如已拿到 token 但后续步骤报错)。
|
||||
state.flush()
|
||||
|
||||
res := &LoginResult{
|
||||
OK: runErr == nil,
|
||||
Toasts: state.toasts,
|
||||
Browsers: state.browsers,
|
||||
UIRefresh: state.UIRefreshRequested(),
|
||||
Values: values,
|
||||
Cookies: state.snapshotCookies(),
|
||||
}
|
||||
if runErr != nil {
|
||||
res.Error = runErr.Error()
|
||||
}
|
||||
// 登录态以是否拿到凭证(Cookie)为准,避免"只存了表单"被显示成已登录。
|
||||
res.LoggedIn = len(res.Cookies) > 0
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
|
||||
// 保存后书源 JS 的 getVariable() 即可读到。
|
||||
func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variable string) error {
|
||||
if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
|
||||
return fmt.Errorf("变量必须是合法 JSON")
|
||||
}
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
sess.state.SetVariable(variable)
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
|
||||
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
b, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess.state.SetLoginInfo(string(b))
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
|
||||
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
sess.state.SetLoginInfo("")
|
||||
sess.state.SetLoginHeader("")
|
||||
sess.state.clearCookies()
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginBindings 构造登录动作的 JS 作用域绑定。
|
||||
// 对应 legado:result 为表单数据;book/chapter/isLongClick 一并提供。
|
||||
func loginBindings(sess *sourceSession, values map[string]string) map[string]any {
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"result": values,
|
||||
"book": nil,
|
||||
"chapter": nil,
|
||||
"isLongClick": false,
|
||||
"sourceUrl": sess.srcURL(),
|
||||
}
|
||||
}
|
||||
|
||||
func parseLoginInfoValues(raw string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isJSWrapped 判断规则串是否带 @js: / <js> 包裹。
|
||||
func isJSWrapped(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return strings.HasPrefix(s, "@js:") || strings.HasPrefix(s, "<js>")
|
||||
}
|
||||
|
||||
func anyToStr(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 本文件:登录类书源的服务层链路测试。
|
||||
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
|
||||
|
||||
// readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。
|
||||
const readerTestUserID = "test-user"
|
||||
|
||||
// loginTestServer 模拟一个需要登录的书源站点:
|
||||
// - POST /login_api 校验账号密码并下发会话 Cookie
|
||||
// - GET /search 读取 Cookie,无 Cookie 返回 401(模拟登录后才能搜索)
|
||||
func loginTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/login_api":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["register_email"] != "user@example.com" || body["password"] != "pw123456" {
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "SESSION_abcdef123456", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"code":0,"key":"SESSION_abcdef123456"}`))
|
||||
case r.URL.Path == "/search":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=SESSION_abcdef123456") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<html><body>未登录</body></html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body>
|
||||
<div class="item"><h3><a href="/book/9">会员专享书</a></h3><span class="author">作者</span></div>
|
||||
</body></html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func loginTestSourceJSON(t *testing.T, server string) string {
|
||||
t.Helper()
|
||||
// loginUrl 是登录逻辑:读 result 里的表单值 → 调登录接口 → 写入 Cookie。
|
||||
// BaseUrl() 在真实书源里由 jsLib 提供,这里一并定义。
|
||||
loginJS := `function BaseUrl() { return baseUrl; }
|
||||
function login(flag) {
|
||||
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
|
||||
var res = java.ajax(BaseUrl() + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
|
||||
var data = JSON.parse(res);
|
||||
if (data.code == 0) {
|
||||
setAllCookies('qttoken=' + data.key);
|
||||
java.toast('登录成功');
|
||||
return true;
|
||||
}
|
||||
java.toast(data.msg || '登录失败');
|
||||
return false;
|
||||
}
|
||||
function setAllCookies(ck) { cookie.setCookie(BaseUrl(), ck); }`
|
||||
|
||||
loginUI := []map[string]any{
|
||||
{"name": "邮箱", "type": "text"},
|
||||
{"name": "密码", "type": "password"},
|
||||
{"name": "登录", "type": "button", "action": "login(true)"},
|
||||
}
|
||||
uiJSON, err := json.Marshal(loginUI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": server,
|
||||
"bookSourceName": "登录源",
|
||||
"bookSourceType": 0,
|
||||
"enabledCookieJar": true,
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": string(uiJSON),
|
||||
"searchUrl": server + "/search",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
"author": "class.author@text",
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
|
||||
t.Helper()
|
||||
// 唯一库名避免同包测试互相污染;cache=shared 让连接池共享同一份内存库
|
||||
// (多源搜索会并发写会话状态,而 :memory: 下每个连接各自一个库,
|
||||
// 并发写入对后续读取不可见,会造成测试假失败)。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
svc := NewReaderService(cfg, zap.NewNop(), repos)
|
||||
return svc, repos
|
||||
}
|
||||
|
||||
// prepareLoginSource 导入测试书源并返回其 ID。
|
||||
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcs, err := svc.ListSources(t.Context())
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("导入后应能读到书源: %v", err)
|
||||
}
|
||||
return srcs[0].ID
|
||||
}
|
||||
|
||||
// TestSourceLoginEndToEnd 登录成功后:
|
||||
// - Cookie 落库(可再次读出)
|
||||
// - 搜索请求自动携带 Cookie 并通过鉴权
|
||||
// - 登出后 Cookie 清除,搜索重新变成未登录
|
||||
func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, repos := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
}
|
||||
|
||||
// ── 登录 ──
|
||||
res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatalf("登录未成功: %+v", res)
|
||||
}
|
||||
if !res.LoggedIn {
|
||||
t.Fatalf("登录后应处于已登录态: %+v", res)
|
||||
}
|
||||
if len(res.Cookies) == 0 {
|
||||
t.Fatalf("登录后应有 Cookie 落库: %+v", res)
|
||||
}
|
||||
|
||||
// ── 登录态应落库(换一个 service 实例仍可读到)──
|
||||
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
|
||||
info, err := svc2.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.LoggedIn || len(info.Cookies) == 0 {
|
||||
t.Fatalf("新实例未能读到已持久化的登录态: %+v", info)
|
||||
}
|
||||
if info.Values["邮箱"] != "user@example.com" {
|
||||
t.Fatalf("登录表单值未持久化: %+v", info.Values)
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("已登录后搜索不应失败: %+v", skipped)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "会员专享书" {
|
||||
t.Fatalf("搜索结果异常: %+v", books)
|
||||
}
|
||||
|
||||
// ── 登出:Cookie 清除,搜索重新未登录 ──
|
||||
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginWrongPassword 密码错误时登录动作应报失败并给出书源提示。
|
||||
func TestSourceLoginWrongPassword(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "wrong",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.LoggedIn {
|
||||
t.Fatalf("密码错误不应处于已登录态: %+v", res)
|
||||
}
|
||||
if len(res.Toasts) == 0 {
|
||||
t.Fatalf("应把书源的失败提示回传:%+v", res)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.Toasts, " "), "密码错误") {
|
||||
t.Fatalf("提示语未透传: %v", res.Toasts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginInfo_ExposesUIFields 登录界面描述应包含 loginUi 的控件定义。
|
||||
func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
info, err := svc.GetSourceLogin(t.Context(), readerTestUserID, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.HasLoginJS {
|
||||
t.Fatal("应识别到 loginUrl")
|
||||
}
|
||||
var names []string
|
||||
for _, f := range info.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, want := range []string{"邮箱", "密码", "登录"} {
|
||||
if !containsStr(names, want) {
|
||||
t.Fatalf("loginUi 字段缺失 %q: %v", want, names)
|
||||
}
|
||||
}
|
||||
// 密码字段类型应保留,前端据此用 password 输入框
|
||||
for _, f := range info.Fields {
|
||||
if f.Name == "密码" && f.Type != "password" {
|
||||
t.Fatalf("密码字段类型 = %q", f.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateEncryptedAtRest 登录信息与 Cookie 应加密落库。
|
||||
func TestSourceStateEncryptedAtRest(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, repos := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if _, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err := repos.Reader.GetSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repos.Reader.GetSourceState(ctx, src.SourceURL)
|
||||
if err != nil || st == nil {
|
||||
t.Fatalf("未找到会话状态: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(st.LoginInfo, "enc:v1:") {
|
||||
t.Fatalf("登录信息应加密存储,实际: %q", st.LoginInfo)
|
||||
}
|
||||
if !strings.HasPrefix(st.Cookies, "enc:v1:") {
|
||||
t.Fatalf("Cookie 应加密存储,实际: %q", st.Cookies)
|
||||
}
|
||||
if strings.Contains(st.LoginInfo, "pw123456") {
|
||||
t.Fatal("明文密码出现在库中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceVariableRoundTrip 源变量可通过接口读写,并被书源 JS getVariable 读到。
|
||||
func TestSourceVariableRoundTrip(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(info.Variable), &m); err != nil {
|
||||
t.Fatalf("变量回读失败: %q", info.Variable)
|
||||
}
|
||||
if m["线路"] != "https://v2.example.com" {
|
||||
t.Fatalf("变量值不符: %+v", m)
|
||||
}
|
||||
|
||||
// 非法 JSON 应被拒绝
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, "not-json"); err == nil {
|
||||
t.Fatal("非法 JSON 变量应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestEnabledCookieJarGating enabledCookieJar=false 时不再自动保存响应 Set-Cookie,
|
||||
// 但书源 JS 主动 cookie.setCookie 写入的仍应保留(对应 legado 语义)。
|
||||
func TestEnabledCookieJarGating(t *testing.T) {
|
||||
// 站点在响应里下发 Set-Cookie
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 造一个 searchUrl 指向该站点、enabledCookieJar=false 的书源
|
||||
srcJSON := `{
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"bookSourceName": "无 CookieJar 源",
|
||||
"bookSourceType": 0,
|
||||
"enabledCookieJar": false,
|
||||
"searchUrl": "` + srv.URL + `/search",
|
||||
"ruleSearch": {"bookList":"class.item","name":"tag.h3@tag.a@text","bookUrl":"tag.h3@tag.a@href"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
src, _ := reposReaderURL(ctx, svc, sourceID)
|
||||
st := svc.newSourceState(ctx, src)
|
||||
if got := st.GetCookie(srv.URL); strings.Contains(got, "auto=from-response") {
|
||||
t.Fatalf("enabledCookieJar=false 时不应自动保存 Set-Cookie: %q", got)
|
||||
}
|
||||
|
||||
// 对照:开启时应当捕获
|
||||
srcJSONOn := strings.Replace(srcJSON, `"enabledCookieJar": false`, `"enabledCookieJar": true`, 1)
|
||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
|
||||
}))
|
||||
defer srv2.Close()
|
||||
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
|
||||
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
t.Fatalf("enabledCookieJar 默认开启时应捕获 Set-Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// reposReaderURL 取书源的 SourceURL(会话状态按它索引)。
|
||||
func reposReaderURL(ctx context.Context, svc *ReaderService, sourceID string) (string, error) {
|
||||
src, err := svc.repo.GetSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src.SourceURL, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 阅读器偏好(每个用户一条,见 model.ReaderProfile)。
|
||||
//
|
||||
// 这些值原本只存在浏览器 localStorage,属于设备级;现在按用户落库,让主题、
|
||||
// 排版、听书、书架展示设置能跨设备一致。服务端只做「存储 + 收敛」,
|
||||
// 具体语义(哪些主题、怎么排版)仍由前端决定,所以这里只校验取值范围与枚举,
|
||||
// 不解释它们。
|
||||
|
||||
// 取值范围与前端 readerSettings store 里的 clamp 完全一致,两边改动需同步。
|
||||
const (
|
||||
readerFontSizeMin, readerFontSizeMax = 14, 32
|
||||
readerLineHeightMin, readerLineHeightMax = 1.4, 2.6
|
||||
readerParaSpacingMin, readerParaSpacingMax = 0, 32
|
||||
readerAudioSpeedMin, readerAudioSpeedMax = 0.5, 3
|
||||
readerAudioTimerMin, readerAudioTimerMax = 0, 180
|
||||
readerGridColumnsMin, readerGridColumnsMax = 0, 6
|
||||
|
||||
// readerThemeIDDefault 主题标识的自愈值:themeId 不枚举校验(前端可以自由
|
||||
// 新增主题),只限制长度,空值时回落到内置的「预设1」。
|
||||
readerThemeIDDefault = "preset1"
|
||||
readerThemeIDMaxLen = 32
|
||||
)
|
||||
|
||||
// 前端定义的枚举取值;未知值一律回落成默认项,避免脏值把阅读器置于不可用状态。
|
||||
var (
|
||||
readerPageModes = []string{"page", "scroll"}
|
||||
readerShelfLayout = []string{"grid", "list", "compact"}
|
||||
readerShelfSorts = []string{"recent", "update", "mixed", "name", "author", "manual"}
|
||||
)
|
||||
|
||||
// ReaderSettings 阅读器偏好的读写载荷(与前端 ReaderSettingsProfile 字段一一对应)。
|
||||
type ReaderSettings struct {
|
||||
ThemeID string `json:"theme_id"`
|
||||
Night bool `json:"night"`
|
||||
PageMode string `json:"page_mode"`
|
||||
FontSize int `json:"font_size"`
|
||||
LineHeight float64 `json:"line_height"`
|
||||
ParagraphSpacing int `json:"paragraph_spacing"`
|
||||
|
||||
AudioSpeed float64 `json:"audio_speed"`
|
||||
AudioTimerMinutes int `json:"audio_timer_minutes"`
|
||||
|
||||
ShelfLayout string `json:"shelf_layout"`
|
||||
ShelfGridColumns int `json:"shelf_grid_columns"`
|
||||
ShelfSort string `json:"shelf_sort"`
|
||||
ShelfShowUnread bool `json:"shelf_show_unread"`
|
||||
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
|
||||
}
|
||||
|
||||
// GetReaderSettings 读用户阅读器偏好。
|
||||
// 该用户还没保存过时返回 (nil, nil):由前端用它本地的值播种,避免这里再维护一份默认值。
|
||||
func (s *ReaderService) GetReaderSettings(ctx context.Context, userID string) (*ReaderSettings, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row, err := s.repo.GetReaderProfile(ctx, userID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
out := settingsFromProfile(*row)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveReaderSettings 覆盖保存用户阅读器偏好,落库前先做范围收敛。
|
||||
func (s *ReaderService) SaveReaderSettings(ctx context.Context, userID string, in ReaderSettings) (*ReaderSettings, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("缺少用户信息")
|
||||
}
|
||||
sanitized := sanitizeReaderSettings(in)
|
||||
row := &model.ReaderProfile{UserID: userID}
|
||||
applySettingsToProfile(row, sanitized)
|
||||
if err := s.repo.SaveReaderProfile(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := settingsFromProfile(*row)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// sanitizeReaderSettings 把入参收敛到合法范围:数值夹到区间、枚举未知即回落默认、
|
||||
// 主题标识去空白并限长。
|
||||
func sanitizeReaderSettings(in ReaderSettings) ReaderSettings {
|
||||
return ReaderSettings{
|
||||
ThemeID: sanitizeThemeID(in.ThemeID),
|
||||
Night: in.Night,
|
||||
PageMode: oneOf(in.PageMode, readerPageModes, readerPageModes[0]),
|
||||
FontSize: clampInt(in.FontSize, readerFontSizeMin, readerFontSizeMax),
|
||||
LineHeight: roundTo1(clampFloat(in.LineHeight, readerLineHeightMin, readerLineHeightMax)),
|
||||
ParagraphSpacing: clampInt(in.ParagraphSpacing, readerParaSpacingMin, readerParaSpacingMax),
|
||||
|
||||
AudioSpeed: roundTo1(clampFloat(in.AudioSpeed, readerAudioSpeedMin, readerAudioSpeedMax)),
|
||||
AudioTimerMinutes: clampInt(in.AudioTimerMinutes, readerAudioTimerMin, readerAudioTimerMax),
|
||||
|
||||
ShelfLayout: oneOf(in.ShelfLayout, readerShelfLayout, readerShelfLayout[0]),
|
||||
ShelfGridColumns: clampInt(in.ShelfGridColumns, readerGridColumnsMin, readerGridColumnsMax),
|
||||
ShelfSort: oneOf(in.ShelfSort, readerShelfSorts, readerShelfSorts[0]),
|
||||
ShelfShowUnread: in.ShelfShowUnread,
|
||||
ShelfShowUpdateTime: in.ShelfShowUpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeThemeID(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || len(v) > readerThemeIDMaxLen {
|
||||
return readerThemeIDDefault
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func settingsFromProfile(p model.ReaderProfile) ReaderSettings {
|
||||
return ReaderSettings{
|
||||
ThemeID: p.ThemeID,
|
||||
Night: p.Night,
|
||||
PageMode: p.PageMode,
|
||||
FontSize: p.FontSize,
|
||||
LineHeight: p.LineHeight,
|
||||
ParagraphSpacing: p.ParagraphSpacing,
|
||||
|
||||
AudioSpeed: p.AudioSpeed,
|
||||
AudioTimerMinutes: p.AudioTimerMinutes,
|
||||
|
||||
ShelfLayout: p.ShelfLayout,
|
||||
ShelfGridColumns: p.ShelfGridColumns,
|
||||
ShelfSort: p.ShelfSort,
|
||||
ShelfShowUnread: p.ShelfShowUnread,
|
||||
ShelfShowUpdateTime: p.ShelfShowUpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func applySettingsToProfile(p *model.ReaderProfile, in ReaderSettings) {
|
||||
p.ThemeID = in.ThemeID
|
||||
p.Night = in.Night
|
||||
p.PageMode = in.PageMode
|
||||
p.FontSize = in.FontSize
|
||||
p.LineHeight = in.LineHeight
|
||||
p.ParagraphSpacing = in.ParagraphSpacing
|
||||
|
||||
p.AudioSpeed = in.AudioSpeed
|
||||
p.AudioTimerMinutes = in.AudioTimerMinutes
|
||||
|
||||
p.ShelfLayout = in.ShelfLayout
|
||||
p.ShelfGridColumns = in.ShelfGridColumns
|
||||
p.ShelfSort = in.ShelfSort
|
||||
p.ShelfShowUnread = in.ShelfShowUnread
|
||||
p.ShelfShowUpdateTime = in.ShelfShowUpdateTime
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clampFloat(v, lo, hi float64) float64 {
|
||||
// JSON 不会给出 NaN,这里是防御性的(例如将来接了别的调用方)
|
||||
if math.IsNaN(v) || v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func roundTo1(v float64) float64 {
|
||||
return math.Round(v*10) / 10
|
||||
}
|
||||
|
||||
// oneOf 返回 v(当它属于 allowed 时),否则返回 fallback。
|
||||
func oneOf(v string, allowed []string, fallback string) string {
|
||||
for _, a := range allowed {
|
||||
if v == a {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
|
||||
func TestProxyURLSignRoundTrip(t *testing.T) {
|
||||
s := &ReaderService{cfg: &config.Config{Secrets: config.SecretsConfig{JWTSecret: "test-secret"}}}
|
||||
const raw = "https://cdn.example.com/audio/ep1.mp3?token=abc"
|
||||
signed := s.ProxyURL("book-1", raw)
|
||||
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("b") != "book-1" {
|
||||
t.Fatalf("bookID = %q", q.Get("b"))
|
||||
}
|
||||
restored, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), q.Get("s"))
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if restored != raw {
|
||||
t.Fatalf("restored = %q", restored)
|
||||
}
|
||||
// 篡改签名必须被拒绝
|
||||
if _, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), "00000000000000000000000000000000"); err == nil {
|
||||
t.Fatal("expected signature rejection")
|
||||
}
|
||||
// 换书签必须被拒绝
|
||||
if _, err := s.VerifyProxyURL("book-2", q.Get("u"), q.Get("s")); err == nil {
|
||||
t.Fatal("expected cross-book signature rejection")
|
||||
}
|
||||
// 已是代理地址的不再二次包裹
|
||||
if again := s.ProxyURL("book-1", signed); again != signed {
|
||||
t.Fatalf("double wrap: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceReferer 验证默认 Referer 只在 origin 是真正的 http(s) 书源地址时
|
||||
// 才生成。聚合类书源的 origin 是显示名(如「光遇聚合」),拿它当 Referer 会被
|
||||
// 图床判盗链,返回一张「请到本网站阅读」的占位图。
|
||||
func TestSourceReferer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{"普通书源地址", "https://www.example.com", "https://www.example.com/"},
|
||||
{"带尾斜杠", "https://www.example.com/", "https://www.example.com/"},
|
||||
{"带子路径", "https://www.example.com/site", "https://www.example.com/site/"},
|
||||
{"聚合书源显示名", "光遇聚合", ""},
|
||||
{"空值", "", ""},
|
||||
{"只有空白", " ", ""},
|
||||
{"非 http 协议", "ftp://example.com", ""},
|
||||
{"没有主机", "https://", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sourceReferer(c.origin); got != c.want {
|
||||
t.Errorf("%s: sourceReferer(%q) = %q,期望 %q", c.name, c.origin, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaRetriesTransientFailure 验证图床偶发 403(并发限流)会被重试掉,
|
||||
// 否则漫画一屏并发取图时总有几页留成破图。
|
||||
func TestFetchMediaRetriesTransientFailure(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) < 3 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
_, _ = w.Write([]byte("real-image-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/1.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d,期望 200(前两次 403 应被重试掉)", resp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if string(data) != "real-image-bytes" {
|
||||
t.Fatalf("body = %q", data)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaDoesNotRetryNotFound 验证确定性的 4xx(如图片不存在)不做无谓重试,
|
||||
// 避免首屏白白多等两轮退避。
|
||||
func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/missing.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("状态码 = %d,期望 404", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRateLimiter 单源限速。
|
||||
//
|
||||
// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封
|
||||
// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。
|
||||
// 对应 legado 的 ConcurrentRateLimiter,支持两种写法:
|
||||
//
|
||||
// "1000" 每次请求至少间隔 1000ms
|
||||
// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔)
|
||||
//
|
||||
// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。
|
||||
type sourceRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
slots map[string]time.Time
|
||||
}
|
||||
|
||||
// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。
|
||||
const maxRateGapMs = 30000
|
||||
|
||||
func newSourceRateLimiter() *sourceRateLimiter {
|
||||
return &sourceRateLimiter{slots: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// wait 按源限速等待;key 为空或 rate 无法解析时不等待。
|
||||
func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) {
|
||||
gap := parseConcurrentRate(rate)
|
||||
if gap <= 0 || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
slot := now
|
||||
if next, ok := l.slots[key]; ok && next.After(now) {
|
||||
slot = next
|
||||
}
|
||||
l.slots[key] = slot.Add(gap)
|
||||
l.mu.Unlock()
|
||||
sleepWithContext(ctx, slot.Sub(now))
|
||||
}
|
||||
|
||||
// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。
|
||||
func parseConcurrentRate(rate string) time.Duration {
|
||||
rate = strings.TrimSpace(rate)
|
||||
if rate == "" {
|
||||
return 0
|
||||
}
|
||||
gapMs := 0
|
||||
if i := strings.IndexByte(rate, '/'); i > 0 {
|
||||
count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i]))
|
||||
interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:]))
|
||||
if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = interval / count
|
||||
} else {
|
||||
n, err := strconv.Atoi(rate)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = n
|
||||
}
|
||||
if gapMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
if gapMs > maxRateGapMs {
|
||||
gapMs = maxRateGapMs
|
||||
}
|
||||
return time.Duration(gapMs) * time.Millisecond
|
||||
}
|
||||
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseConcurrentRate 书源 concurrentRate 的两种写法。
|
||||
func TestParseConcurrentRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"abc", 0},
|
||||
{"1000", 1000 * time.Millisecond},
|
||||
{" 500 ", 500 * time.Millisecond},
|
||||
{"3/1000", 333 * time.Millisecond},
|
||||
{"1/2000", 2000 * time.Millisecond},
|
||||
{"3/0", 0},
|
||||
{"0/1000", 0},
|
||||
{"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseConcurrentRate(c.in); got != c.want {
|
||||
t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。
|
||||
func TestRateLimiterSpacesRequests(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
l.wait(t.Context(), "src", "120") // 120ms
|
||||
l.wait(t.Context(), "src", "120")
|
||||
if elapsed := time.Since(start); elapsed < 110*time.Millisecond {
|
||||
t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed)
|
||||
}
|
||||
// 不同源互不影响
|
||||
start = time.Now()
|
||||
l.wait(t.Context(), "other", "500")
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("换源后不该等待,实际 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。
|
||||
func TestRateLimiterIgnoresEmptyRate(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
l.wait(t.Context(), "src", "")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("未声明限速却等待了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryableStatus 只有可恢复的失败才重试。
|
||||
func TestRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 429, 500, 502, 503, 504} {
|
||||
if !retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 301, 400, 401, 404, 410} {
|
||||
if retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 不该重试", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestRewriteContentImageMarkers 文本型漫画源正文里的 <img> 要转成前端认识的 [img] 标记,
|
||||
// 且地址要绝对化、过签名代理(有些图需要书源防盗链头才取得到)。
|
||||
func TestRewriteContentImageMarkers(t *testing.T) {
|
||||
proxy := func(u string) string { return "/proxy?u=" + url.QueryEscape(u) }
|
||||
content := "第一章开始\n" +
|
||||
"<img src=\"https://img.example.com/1.webp\">\n" +
|
||||
"<img src='/img/2.webp'/>\n" +
|
||||
"中间夹了图片 <img src=\"https://img.example.com/3.webp\"> 的这一行不动\n" +
|
||||
"结束"
|
||||
got := rewriteContentImageMarkers(content, "https://site.example.com/ch/1", proxy)
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("行数 = %d,期望 5\n%s", len(lines), got)
|
||||
}
|
||||
if lines[0] != "第一章开始" || lines[4] != "结束" {
|
||||
t.Fatalf("普通文本行被改动: %q / %q", lines[0], lines[4])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], imgMarkerPrefix) {
|
||||
t.Fatalf("第 2 行没有变成 [img] 标记: %q", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[1], url.QueryEscape("https://img.example.com/1.webp")) {
|
||||
t.Fatalf("第 2 行地址没走代理: %q", lines[1])
|
||||
}
|
||||
// 相对地址按章节地址绝对化
|
||||
if !strings.Contains(lines[2], url.QueryEscape("https://site.example.com/img/2.webp")) {
|
||||
t.Fatalf("相对图片地址没有绝对化: %q", lines[2])
|
||||
}
|
||||
// 段落中间的内嵌图片保持原样,避免破坏文本源语义
|
||||
if !strings.Contains(lines[3], "<img src=") {
|
||||
t.Fatalf("段落中间的 <img> 不该被改写: %q", lines[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteContentImageMarkersWithoutProxy 不需要代理时(如预览链路)直接落到绝对地址。
|
||||
func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
got := rewriteContentImageMarkers(`<img src="https://img.example.com/1.webp">`, "https://site.example.com/ch/1", nil)
|
||||
if got != imgMarkerPrefix+"https://img.example.com/1.webp" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
// 没有 <img> 的内容原样返回(含 null 字节之外的普通文本)
|
||||
const plain = "纯文本\n第二行"
|
||||
if got := rewriteContentImageMarkers(plain, "", nil); got != plain {
|
||||
t.Fatalf("无图内容被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
|
||||
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
|
||||
func TestSearchCheckKeyWord(t *testing.T) {
|
||||
mk := func(kw string) *BookSource {
|
||||
return &BookSource{RuleSearch: &SearchRule{CheckKeyWord: &kw}}
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"登录武林系统", "登录武林系统"},
|
||||
{" 空格清理 ", "空格清理"},
|
||||
{"", ""},
|
||||
{"http://example.com", ""},
|
||||
{"a::b", ""},
|
||||
{"a++b", ""},
|
||||
{"a--b", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mk(c.in).SearchCheckKeyWord(); got != c.want {
|
||||
t.Errorf("SearchCheckKeyWord(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if (&BookSource{}).SearchCheckKeyWord() != "" {
|
||||
t.Fatal("没有 ruleSearch 时应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchReportsCheckKeyWordMiss 搜索 0 条且响应里没有书源声明的校验关键字时,
|
||||
// 必须给出「疑似风控」的原因,而不是静默返回 0 条 —— 用户才知道是源的问题。
|
||||
func TestSearchReportsCheckKeyWordMiss(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 风控/失效时站点返回的就是这种没有结果的响应
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err == nil {
|
||||
t.Fatal("0 结果且校验关键字缺失时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), kw) {
|
||||
t.Fatalf("错误信息里应带上校验关键字,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchKeepsEmptyResultWhenKeywordPresent 响应里有关键字(站点正常,只是没有匹配的书)
|
||||
// 时不该报错,只是结果为空。
|
||||
func TestSearchKeepsEmptyResultWhenKeywordPresent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"hint":"登录武林系统","results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
books, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("站点正常时不该报错: %v", err)
|
||||
}
|
||||
if len(books) != 0 {
|
||||
t.Fatalf("结果数 = %d,期望 0", len(books))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
const e2eBookHTML = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div class="box" id="main">
|
||||
<div class="item"><h3><a href="/book/1">斗破苍穹</a></h3><span class="author">天蚕土豆</span></div>
|
||||
<div class="item"><h3><a href="/book/2">凡人修仙传</a></h3><span class="author">忘语</span></div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
const e2eBookInfoHTML = `<html><body>
|
||||
<div class="info"><h1>斗破苍穹</h1><span class="author">天蚕土豆</span>
|
||||
<p class="intro">三十年河东三十年河西</p>
|
||||
<a class="toc" href="/book/1/toc.html">查看目录</a></div>
|
||||
</body></html>`
|
||||
|
||||
const e2eTocHTML = `<html><body>
|
||||
<ul class="chapters">
|
||||
<li class="vol">第一卷</li>
|
||||
<li><a href="/book/1/c1.html">第一章 陨落的天才</a></li>
|
||||
<li><a href="/book/1/c2.html">第二章 斗气大陆</a></li>
|
||||
</ul>
|
||||
</body></html>`
|
||||
|
||||
const e2eContentHTML = `<html><body><div id="content"> 魂殿来犯,<br>萧炎浴火重生。 </div>
|
||||
<a class="next" href="/book/1/c1_2.html">下一页</a></body></html>`
|
||||
|
||||
const e2eContentPage2HTML = `<html><body><div id="content">少女微凉的手掌传来。</div></body></html>`
|
||||
|
||||
// e2eServer 模拟一个完整的书源站点:搜索/详情/目录/正文(含 nextContentUrl 翻页)。
|
||||
func e2eServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/search/"):
|
||||
_, _ = w.Write([]byte(e2eBookHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/c1_2"):
|
||||
_, _ = w.Write([]byte(e2eContentPage2HTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/c"):
|
||||
_, _ = w.Write([]byte(e2eContentHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// e2eSource 对齐 legado 书源 JSON 结构,覆盖 jsoup 全部四段规则。
|
||||
func e2eSourceJSON(server string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "测试源",
|
||||
"bookSourceType": 0,
|
||||
"searchUrl": "%s/search/{{key}}/1.html",
|
||||
"ruleSearch": {
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
"author": "class.author@text"
|
||||
},
|
||||
"ruleBookInfo": {
|
||||
"name": "class.info@tag.h1@text",
|
||||
"author": "class.info@class.author@text",
|
||||
"intro": "class.info@class.intro@text",
|
||||
"tocUrl": "class.info@class.toc@href"
|
||||
},
|
||||
"ruleToc": {
|
||||
"chapterList": "class.chapters@tag.li",
|
||||
"chapterName": "tag.a@text",
|
||||
"chapterUrl": "tag.a@href",
|
||||
"isVolume": "tag.a@text"
|
||||
},
|
||||
"ruleContent": {
|
||||
"content": "id.content@textNodes",
|
||||
"nextContentUrl": "class.next@href"
|
||||
}
|
||||
}`, server, server)
|
||||
}
|
||||
|
||||
// newE2EEngine 执行与 ReaderService 相同的链路(不含 DB):
|
||||
// ParseAnalyzeUrl → execute → AnalyzeRule。
|
||||
type e2eEngine struct {
|
||||
server string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (e *e2eEngine) fetch(t *testing.T, urlRule, key string, page int) (*rule.AnalyzeRule, string) {
|
||||
t.Helper()
|
||||
req, err := rule.ParseAnalyzeUrl(urlRule, key, page, e.server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
t.Fatalf("unsupported: %v", req.Unsupported)
|
||||
}
|
||||
httpReq, _ := http.NewRequest("GET", req.URL, nil)
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
resp, err := e.client.Do(httpReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
ar := rule.NewAnalyzeRule()
|
||||
ar.SetContent(string(body), resp.Request.URL.String())
|
||||
return ar, resp.Request.URL.String()
|
||||
}
|
||||
|
||||
func TestEndToEndSourceChain(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
bs, err := ParseBookSource(e2eSourceJSON(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := &e2eEngine{server: srv.URL, client: srv.Client()}
|
||||
|
||||
// ── 搜索 ──
|
||||
ar, _ := engine.fetch(t, SPtr(bs.SearchURL), "斗罗", 1)
|
||||
sr := bs.RuleSearch
|
||||
els, err := ar.GetElements(SPtr(sr.BookList))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(els) != 2 {
|
||||
t.Fatalf("search elements = %d", len(els))
|
||||
}
|
||||
name, _ := ar.GetString(SPtr(sr.Name), els[0], false)
|
||||
if name != "斗破苍穹" {
|
||||
t.Fatalf("search name = %q", name)
|
||||
}
|
||||
bookURL, _ := ar.GetString(SPtr(sr.BookURL), els[0], true)
|
||||
if !strings.Contains(bookURL, "/book/1") {
|
||||
t.Fatalf("bookURL = %q", bookURL)
|
||||
}
|
||||
|
||||
// ── 详情 ──
|
||||
ar, _ = engine.fetch(t, bookURL, "", 0)
|
||||
bir := bs.RuleBookInfo
|
||||
if got, _ := ar.GetString(SPtr(bir.Name), nil, false); got != "斗破苍穹" {
|
||||
t.Fatalf("info name = %q", got)
|
||||
}
|
||||
if got, _ := ar.GetString(SPtr(bir.Intro), nil, false); got != "三十年河东三十年河西" {
|
||||
t.Fatalf("info intro = %q", got)
|
||||
}
|
||||
tocURL, _ := ar.GetString(SPtr(bir.TocURL), nil, true)
|
||||
if !strings.Contains(tocURL, "/book/1/toc") {
|
||||
t.Fatalf("tocURL = %q", tocURL)
|
||||
}
|
||||
|
||||
// ── 目录 ──
|
||||
ar, _ = engine.fetch(t, tocURL, "", 0)
|
||||
tr := bs.RuleToc
|
||||
chEls, err := ar.GetElements(SPtr(tr.ChapterList))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chEls) != 3 {
|
||||
t.Fatalf("chapters = %d", len(chEls))
|
||||
}
|
||||
var chapters []TocChapter
|
||||
for i, el := range chEls {
|
||||
title, _ := ar.GetString(SPtr(tr.ChapterName), el, false)
|
||||
if title == "" {
|
||||
continue // 卷名行没有 <a>,取不到标题(服务层同样跳过)
|
||||
}
|
||||
url, _ := ar.GetString(SPtr(tr.ChapterURL), el, true)
|
||||
chapters = append(chapters, TocChapter{Index: i, Title: title, URL: url})
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("chapters = %d", len(chapters))
|
||||
}
|
||||
if chapters[0].Title != "第一章 陨落的天才" {
|
||||
t.Fatalf("chapter0 = %+v", chapters[0])
|
||||
}
|
||||
|
||||
// ── 正文(含 nextContentUrl 翻页合并) ──
|
||||
var parts []string
|
||||
url := chapters[0].URL
|
||||
for i := 0; i < 5; i++ {
|
||||
ar, finalURL := engine.fetch(t, url, "", 0)
|
||||
list, err := ar.GetStringList(SPtr(bs.RuleContent.Content), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts = append(parts, strings.Join(list, "\n"))
|
||||
next, _ := ar.GetString(SPtr(bs.RuleContent.NextContentURL), nil, true)
|
||||
if next == "" || next == url || next == finalURL {
|
||||
break
|
||||
}
|
||||
url = next
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
if !strings.Contains(content, "萧炎浴火重生") || !strings.Contains(content, "少女微凉的手掌") {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 纯函数测试:导入识别 / 搜索合并 ────────────────────────────────────────
|
||||
|
||||
func TestParseSourcePayload(t *testing.T) {
|
||||
// 数组
|
||||
arr := `[{"bookSourceUrl":"http://a.com","bookSourceName":"A"},{"bookSourceUrl":"http://b.com","bookSourceName":"B"}]`
|
||||
if got := ParseSourcePayload(arr); len(got) != 2 {
|
||||
t.Fatalf("array payload = %d", len(got))
|
||||
}
|
||||
// 单对象
|
||||
single := `{"bookSourceUrl":"http://a.com","bookSourceName":"A"}`
|
||||
if got := ParseSourcePayload(single); len(got) != 1 {
|
||||
t.Fatalf("single payload = %d", len(got))
|
||||
}
|
||||
// Base64
|
||||
b64 := base64StdEncode(single)
|
||||
if got := ParseSourcePayload(b64); len(got) != 1 {
|
||||
t.Fatalf("base64 payload = %d", len(got))
|
||||
}
|
||||
// UTF-8 BOM(Windows 导出文件常见)
|
||||
if got := ParseSourcePayload("\uFEFF" + arr); len(got) != 2 {
|
||||
t.Fatalf("bom array payload = %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSearchResults(t *testing.T) {
|
||||
hit := func(name, author, origin string) searchHit {
|
||||
return searchHit{book: SearchBook{
|
||||
Name: name, Author: author,
|
||||
Origins: []SearchOrigin{{OriginName: origin, BookURL: "u"}},
|
||||
}}
|
||||
}
|
||||
hits := []searchHit{
|
||||
hit("遮天", "辰东", "源C"),
|
||||
hit("斗破苍穹", "天蚕土豆", "源B"),
|
||||
hit("斗破苍穹", "天蚕土豆", "源A"),
|
||||
hit("斗罗大陆", "唐家三少", "源D"),
|
||||
}
|
||||
merged := mergeSearchResults(hits, "斗")
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("merged = %d", len(merged))
|
||||
}
|
||||
// 精确命中「斗破苍穹」应排第一(tier 0),且合并两源
|
||||
if merged[0].Name != "斗破苍穹" || len(merged[0].Origins) != 2 {
|
||||
t.Fatalf("first = %+v", merged[0])
|
||||
}
|
||||
}
|
||||
|
||||
func base64StdEncode(s string) string {
|
||||
return base64EncodeStr(s)
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/PaesslerAG/jsonpath"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeRule.kt 主类。
|
||||
|
||||
// AnalyzeRule 解析规则获取结果(对应 AnalyzeRule)。
|
||||
type AnalyzeRule struct {
|
||||
content any
|
||||
baseUrl string
|
||||
redirectURL *url.URL
|
||||
isJSON bool
|
||||
isRegex bool
|
||||
|
||||
// jsRunner 由 P2 阶段的 goja 引擎注入;nil 时 JS 规则报 ErrJsUnsupported。
|
||||
jsRunner func(js string, result any) (any, error)
|
||||
|
||||
// 变量层级(对应 chapter → book → ruleData → source)
|
||||
chapterVars map[string]string
|
||||
bookVars map[string]string
|
||||
vars map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName 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
|
||||
}
|
||||
|
||||
// NewAnalyzeRule 创建解析器。
|
||||
func NewAnalyzeRule() *AnalyzeRule {
|
||||
return &AnalyzeRule{ruleCache: map[string][]*SourceRule{}}
|
||||
}
|
||||
|
||||
// SetJSRunner 注入 JS 执行器(P2)。
|
||||
func (a *AnalyzeRule) SetJSRunner(runner func(js string, result any) (any, error)) {
|
||||
a.jsRunner = runner
|
||||
}
|
||||
|
||||
// SetContent 对应 setContent(content, baseUrl)。
|
||||
func (a *AnalyzeRule) SetContent(content any, baseUrl string) *AnalyzeRule {
|
||||
a.content = content
|
||||
switch content.(type) {
|
||||
case *html.Node:
|
||||
a.isJSON = false
|
||||
default:
|
||||
a.isJSON = LooksLikeJSON(anyToString(content))
|
||||
}
|
||||
if baseUrl != "" {
|
||||
a.baseUrl = baseUrl
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetBaseUrl 对应 setBaseUrl。
|
||||
func (a *AnalyzeRule) SetBaseUrl(baseUrl string) *AnalyzeRule {
|
||||
if baseUrl != "" {
|
||||
a.baseUrl = baseUrl
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetRedirectUrl 对应 setRedirectUrl。
|
||||
func (a *AnalyzeRule) SetRedirectUrl(u string) *AnalyzeRule {
|
||||
if strings.HasPrefix(u, "data:") {
|
||||
return a
|
||||
}
|
||||
if parsed, err := url.Parse(u); err == nil {
|
||||
a.redirectURL = parsed
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetChapterContext 设置章节上下文(title 与章节级变量存储)。
|
||||
func (a *AnalyzeRule) SetChapterContext(title string, vars map[string]string) {
|
||||
a.chapterTitle = title
|
||||
if vars != nil {
|
||||
a.chapterVars = vars
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if vars != nil {
|
||||
a.bookVars = vars
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
|
||||
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
|
||||
a.sourceGetter = getter
|
||||
a.sourcePutter = putter
|
||||
}
|
||||
|
||||
// jsonpathGet 包装 PaesslerAG/jsonpath.Get。
|
||||
func jsonpathGet(path string, root any) (v any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
v, err = nil, ErrJsUnsupported
|
||||
}
|
||||
}()
|
||||
return jsonpath.Get(path, root)
|
||||
}
|
||||
|
||||
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
|
||||
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → source。
|
||||
func (a *AnalyzeRule) Put(key, value string) string {
|
||||
switch {
|
||||
case a.chapterVars != nil:
|
||||
a.chapterVars[key] = value
|
||||
case a.bookVars != nil:
|
||||
a.bookVars[key] = value
|
||||
default:
|
||||
if a.vars == nil {
|
||||
a.vars = map[string]string{}
|
||||
}
|
||||
a.vars[key] = value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
|
||||
func (a *AnalyzeRule) Get(key string) string {
|
||||
switch key {
|
||||
case "bookName":
|
||||
if a.bookName != "" {
|
||||
return a.bookName
|
||||
}
|
||||
case "title":
|
||||
if a.chapterTitle != "" {
|
||||
return a.chapterTitle
|
||||
}
|
||||
}
|
||||
for _, store := range []map[string]string{a.chapterVars, a.bookVars, a.vars} {
|
||||
if store != nil {
|
||||
if v, ok := store[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.sourceGetter != nil {
|
||||
if v := a.sourceGetter(key); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── JS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (a *AnalyzeRule) evalJS(js string, result any) (any, error) {
|
||||
if a.jsRunner == nil {
|
||||
return nil, ErrJsUnsupported
|
||||
}
|
||||
return a.jsRunner(js, result)
|
||||
}
|
||||
|
||||
// ─── 规则拆分缓存 ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a *AnalyzeRule) splitSourceRuleCached(ruleStr string) []*SourceRule {
|
||||
if ruleStr == "" {
|
||||
return nil
|
||||
}
|
||||
if cached, ok := a.ruleCache[ruleStr]; ok {
|
||||
return cached
|
||||
}
|
||||
rules := SplitSourceRule(ruleStr, false, a.isJSON, &a.isRegex)
|
||||
a.ruleCache[ruleStr] = rules
|
||||
return rules
|
||||
}
|
||||
|
||||
func (a *AnalyzeRule) putRule(putMap map[string]string) error {
|
||||
for k, v := range putMap {
|
||||
s, err := a.GetString(v, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Put(k, s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeDeps 构造 MakeUpRule 展开内嵌 {{...}} 需要的执行环境。
|
||||
//
|
||||
// content 是当前正在解析的元素。legado 的内嵌规则({{$.x}} / {{@x}})在**当前元素**
|
||||
// 上求值:书源普遍用 `{{$.status}},{{$.score}}` 这种模板拼 kind、用
|
||||
// `{{$.source}} {{$.last_chapter_title}}` 拼最新章节。若拿整份响应去求值,
|
||||
// 这些模板会全部取空——表现为 kind=",,,"、"最新"为空(聚合源尤其明显)。
|
||||
func (a *AnalyzeRule) makeDeps(content any) *RuleDeps {
|
||||
return &RuleDeps{
|
||||
JS: a.evalJS,
|
||||
Rule: func(rule string) (string, error) { return a.GetString(rule, content, false) },
|
||||
Get: a.Get,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 主流程 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetStringList 对应 getStringList(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetStringList(ruleStr string, mContent any, isUrl bool) ([]string, error) {
|
||||
if ruleStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ruleList := a.splitSourceRuleCached(ruleStr)
|
||||
return a.getStringListRules(ruleList, mContent, isUrl)
|
||||
}
|
||||
|
||||
func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, isUrl bool) ([]string, error) {
|
||||
var result any
|
||||
content := mContent
|
||||
if content == nil {
|
||||
content = a.content
|
||||
}
|
||||
if content != nil && len(ruleList) > 0 {
|
||||
result = content
|
||||
for _, sourceRule := range ruleList {
|
||||
if err := a.putRule(sourceRule.putMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
rule := resolved.Rule
|
||||
if rule != "" || resolved.ReplaceRegex == "" {
|
||||
switch sourceRule.Mode {
|
||||
case ModeWebJs:
|
||||
return nil, ErrWebJSUnsupported
|
||||
case ModeJs:
|
||||
result, err = a.evalJS(rule, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ModeJson:
|
||||
result = newJSONAnalyzer(result).getStringList(rule)
|
||||
case ModeXPath:
|
||||
result = newXPathAnalyzer(result).getStringList(rule)
|
||||
case ModeDefault:
|
||||
result = newJsoupAnalyzer(result).getStringList(rule)
|
||||
default:
|
||||
result = rule
|
||||
}
|
||||
}
|
||||
if resolved.ReplaceRegex != "" {
|
||||
if lst, ok := result.([]string); ok {
|
||||
out := make([]string, 0, len(lst))
|
||||
for _, item := range lst {
|
||||
out = append(out, applyReplaceRegex(item, resolved))
|
||||
}
|
||||
result = out
|
||||
} else {
|
||||
result = applyReplaceRegex(resultString(result), resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if s, ok := result.(string); ok {
|
||||
result = strings.Split(s, "\n")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
}
|
||||
switch t := result.(type) {
|
||||
case []string:
|
||||
return t, nil
|
||||
case []any:
|
||||
out := make([]string, len(t))
|
||||
for i, v := range t {
|
||||
out[i] = anyToString(v)
|
||||
}
|
||||
return out, nil
|
||||
case []*html.Node:
|
||||
out := make([]string, len(t))
|
||||
for i, v := range t {
|
||||
out[i] = outerHTML(v)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return []string{anyToString(result)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
return "", nil
|
||||
}
|
||||
ruleList := a.splitSourceRuleCached(ruleStr)
|
||||
return a.getStringRules(ruleList, mContent, isUrl)
|
||||
}
|
||||
|
||||
func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl bool) (string, error) {
|
||||
var result any
|
||||
content := mContent
|
||||
if content == nil {
|
||||
content = a.content
|
||||
}
|
||||
if content != nil && len(ruleList) > 0 {
|
||||
result = content
|
||||
for _, sourceRule := range ruleList {
|
||||
if err := a.putRule(sourceRule.putMap); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
rule := resolved.Rule
|
||||
if rule != "" || resolved.ReplaceRegex == "" {
|
||||
switch sourceRule.Mode {
|
||||
case ModeWebJs:
|
||||
return "", ErrWebJSUnsupported
|
||||
case ModeJs:
|
||||
result, err = a.evalJS(rule, result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
case ModeJson:
|
||||
result = newJSONAnalyzer(result).getString(rule)
|
||||
case ModeXPath:
|
||||
result = newXPathAnalyzer(result).getString(rule)
|
||||
case ModeDefault:
|
||||
if isUrl {
|
||||
result = newJsoupAnalyzer(result).getString0(rule)
|
||||
} else {
|
||||
result = newJsoupAnalyzer(result).getString(rule)
|
||||
}
|
||||
default:
|
||||
result = rule
|
||||
}
|
||||
}
|
||||
if result != nil && resolved.ReplaceRegex != "" {
|
||||
result = applyReplaceRegex(resultString(result), resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = ""
|
||||
}
|
||||
str := resultString(result)
|
||||
if strings.Contains(str, "&") {
|
||||
str = html.UnescapeString(str)
|
||||
}
|
||||
if isUrl {
|
||||
if strings.TrimSpace(str) == "" {
|
||||
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
|
||||
// 但 baseUrl 未必是地址:聚合类书源(如「光遇聚合」)的搜索请求地址
|
||||
// 本身就是 data:;base64,... 参数信封,直接回退会把信封当成封面/书址
|
||||
// 返回,前端 <img> 只能显示破图。故仅在 baseUrl 是 http(s) 地址时回退。
|
||||
if base := strings.TrimSpace(a.baseUrl); isAbsURL(base) {
|
||||
return base, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return a.absolutize(str), nil
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// GetElement 对应 getElement(ruleStr)。
|
||||
func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) {
|
||||
if ruleStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result any
|
||||
content := a.content
|
||||
ruleList := SplitSourceRule(ruleStr, true, a.isJSON, &a.isRegex)
|
||||
if content != nil && len(ruleList) > 0 {
|
||||
result = content
|
||||
for _, sourceRule := range ruleList {
|
||||
if err := a.putRule(sourceRule.putMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
rule := resolved.Rule
|
||||
switch sourceRule.Mode {
|
||||
case ModeRegex:
|
||||
result = regexGetElement(resultString(result), splitNotBlankAndTrim(rule, "&&"), 0)
|
||||
case ModeWebJs:
|
||||
return nil, ErrWebJSUnsupported
|
||||
case ModeJs:
|
||||
result, err = a.evalJS(rule, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ModeJson:
|
||||
result = newJSONAnalyzer(result).getObject(rule)
|
||||
case ModeXPath:
|
||||
result = newXPathAnalyzer(result).getElements(rule)
|
||||
default:
|
||||
result = newJsoupAnalyzer(result).getElements(rule)
|
||||
}
|
||||
if resolved.ReplaceRegex != "" {
|
||||
result = applyReplaceRegex(resultString(result), resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetElements 对应 getElements(ruleStr):列表获取。
|
||||
// 注意:与 Kotlin 一致,这里不做 makeUpRule(无 ## 正则段处理)。
|
||||
func (a *AnalyzeRule) GetElements(ruleStr string) ([]any, error) {
|
||||
var result any
|
||||
content := a.content
|
||||
ruleList := SplitSourceRule(ruleStr, true, a.isJSON, &a.isRegex)
|
||||
if content != nil && len(ruleList) > 0 {
|
||||
result = content
|
||||
for _, sourceRule := range ruleList {
|
||||
if err := a.putRule(sourceRule.putMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
rule := sourceRule.Rule
|
||||
var err error
|
||||
switch sourceRule.Mode {
|
||||
case ModeRegex:
|
||||
result = regexGetElements(resultString(result), splitNotBlankAndTrim(rule, "&&"), 0)
|
||||
case ModeWebJs:
|
||||
return nil, ErrWebJSUnsupported
|
||||
case ModeJs:
|
||||
result, err = a.evalJS(rule, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ModeJson:
|
||||
result = newJSONAnalyzer(result).getList(rule)
|
||||
case ModeXPath:
|
||||
result = newXPathAnalyzer(result).getElements(rule)
|
||||
default:
|
||||
result = newJsoupAnalyzer(result).getElements(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
switch t := result.(type) {
|
||||
case []any:
|
||||
return t, nil
|
||||
case []string:
|
||||
out := make([]any, len(t))
|
||||
for i, v := range t {
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
case []*html.Node:
|
||||
out := make([]any, len(t))
|
||||
for i, v := range t {
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return []any{}, nil
|
||||
}
|
||||
|
||||
// ─── 工具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (a *AnalyzeRule) absolutize(u string) string {
|
||||
if strings.TrimSpace(u) == "" {
|
||||
return ""
|
||||
}
|
||||
if a.redirectURL != nil {
|
||||
return GetAbsoluteURLParsed(a.redirectURL, u)
|
||||
}
|
||||
return GetAbsoluteURL(a.baseUrl, u)
|
||||
}
|
||||
|
||||
// applyReplaceRegex 对应 replaceRegex(result, resolved)。
|
||||
func applyReplaceRegex(result string, r ResolvedSourceRule) string {
|
||||
if r.ReplaceRegex == "" {
|
||||
return result
|
||||
}
|
||||
if r.ReplaceFirst {
|
||||
return regexReplaceFirstOnFirstMatch(r.ReplaceRegex, result, r.Replacement)
|
||||
}
|
||||
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
|
||||
}
|
||||
|
||||
// resultString 对应 Kotlin 的 result.toString()。
|
||||
func resultString(result any) string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case *html.Node:
|
||||
return outerHTML(t)
|
||||
case []string:
|
||||
return strings.Join(t, "\n")
|
||||
case []*html.Node:
|
||||
var sb strings.Builder
|
||||
for _, n := range t {
|
||||
sb.WriteString(outerHTML(n))
|
||||
}
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。
|
||||
// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址,
|
||||
// 回退结果会把封面/书址污染成非图片文本(前端 <img> 直接破图)。
|
||||
func TestGetStringURLFallbackSkipsNonHTTPBaseURL(t *testing.T) {
|
||||
const payload = `{"data":[{"book_name":"全球高武","author":"老鹰吃小鸡"}]}`
|
||||
const envelope = "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ=="
|
||||
|
||||
elementOf := func(t *testing.T, content, baseURL string) (*AnalyzeRule, any) {
|
||||
t.Helper()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(content, baseURL)
|
||||
els, err := a.GetElements("$.data")
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("GetElements: want 1 element, got %d", len(els))
|
||||
}
|
||||
return a, els[0]
|
||||
}
|
||||
|
||||
t.Run("空取值不会把 data: 信封当地址返回", func(t *testing.T) {
|
||||
a, el := elementOf(t, payload, envelope)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("封面应为空,实际 = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("baseUrl 是 http(s) 时仍按 legado 语义回退", func(t *testing.T) {
|
||||
const base = "https://www.example.com/search?q=x"
|
||||
a, el := elementOf(t, payload, base)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("want base fallback %q, got %q", base, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("规则有取值时原样返回", func(t *testing.T) {
|
||||
const withCover = `{"data":[{"book_name":"书","thumb_url":"https://img.example.com/c.jpg"}]}`
|
||||
a, el := elementOf(t, withCover, envelope)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "https://img.example.com/c.jpg" {
|
||||
t.Fatalf("封面 = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
// 注入为 `java` 对象的函数。函数名与 Kotlin 版一一对应(存量书源硬编码),
|
||||
// 实现全部为 Go 原生;浏览器/文件/压缩包类能力明确抛出不支持错误。
|
||||
|
||||
func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
bridgeErr := func(name string, err error) goja.Value {
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s: %v", name, err)))
|
||||
}
|
||||
|
||||
// ── 网络(对应 JsExtensions.ajax/ajaxAll/connect/get/post/head) ──
|
||||
fetchStr := func(name string, urlVal goja.Value, extraHeaders map[string]any) string {
|
||||
if r.cfg.Fetch == nil {
|
||||
bridgeErr(name, ErrJsUnsupported)
|
||||
}
|
||||
urlStr := urlVal.String()
|
||||
if arr, ok := urlVal.Export().([]any); ok && len(arr) > 0 {
|
||||
urlStr = fmt.Sprintf("%v", arr[0]) // 对应 legado:List 取第一个
|
||||
}
|
||||
req, err := ParseAnalyzeUrlWithJS(urlStr, "", 0, r.cfg.BaseURL, r)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
bridgeErr(name, req.Unsupported)
|
||||
}
|
||||
for k, v := range extraHeaders {
|
||||
if _, exists := req.Headers[k]; !exists {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
body, _, _, err := r.cfg.Fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
fetchResp := func(name string, args []goja.Value, method, body string) *goja.Object {
|
||||
if r.cfg.Fetch == nil {
|
||||
bridgeErr(name, ErrJsUnsupported)
|
||||
}
|
||||
urlStr := ""
|
||||
if len(args) > 0 {
|
||||
urlStr = args[0].String()
|
||||
}
|
||||
req, err := ParseAnalyzeUrlWithJS(urlStr, "", 0, r.cfg.BaseURL, r)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
if method == "POST" {
|
||||
req.Method = "POST"
|
||||
req.Body = body
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
req.IsJSON = true
|
||||
} else if body != "" {
|
||||
req.IsForm = true
|
||||
req.Body = encodeParams(body, req.Charset, false)
|
||||
}
|
||||
}
|
||||
if len(args) > 1 && !goja.IsUndefined(args[1]) && !goja.IsNull(args[1]) {
|
||||
if extra, ok := args[1].Export().(map[string]any); ok {
|
||||
for k, v := range extra {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
bridgeErr(name, req.Unsupported)
|
||||
}
|
||||
respBody, finalURL, code, err := r.cfg.Fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
return newResponseObject(vm, respBody, code, finalURL, nil)
|
||||
}
|
||||
|
||||
set("ajax", func(call goja.FunctionCall) goja.Value {
|
||||
extra := map[string]any(nil)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
if m, ok := call.Arguments[1].Export().(map[string]any); ok {
|
||||
extra = m
|
||||
}
|
||||
}
|
||||
return vm.ToValue(fetchStr("ajax", call.Arguments[0], extra))
|
||||
})
|
||||
set("ajaxAll", func(call goja.FunctionCall) goja.Value {
|
||||
var urls []any
|
||||
if len(call.Arguments) > 0 {
|
||||
if arr, ok := call.Arguments[0].Export().([]any); ok {
|
||||
urls = arr
|
||||
}
|
||||
}
|
||||
out := make([]any, 0, len(urls))
|
||||
for _, u := range urls {
|
||||
out = append(out, fetchStr("ajaxAll", vm.ToValue(u), nil))
|
||||
}
|
||||
return vm.ToValue(out)
|
||||
})
|
||||
set("connect", func(call goja.FunctionCall) goja.Value {
|
||||
return fetchResp("connect", call.Arguments, "GET", "")
|
||||
})
|
||||
// get 双语义(对应 Kotlin 重载):get(url, headers) 走网络,get(key) 读变量
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
return fetchResp("get", call.Arguments, "GET", "")
|
||||
}
|
||||
key := stringArg(call, 0)
|
||||
if a != nil {
|
||||
return vm.ToValue(a.Get(key))
|
||||
}
|
||||
return vm.ToValue(r.vars[key])
|
||||
})
|
||||
set("post", func(call goja.FunctionCall) goja.Value {
|
||||
body := stringArgOr(call, 1, "")
|
||||
return fetchResp("post", call.Arguments, "POST", body)
|
||||
})
|
||||
set("head", func(call goja.FunctionCall) goja.Value {
|
||||
return fetchResp("head", call.Arguments, "HEAD", "")
|
||||
})
|
||||
|
||||
// ── 规则回调(桥回当前 AnalyzeRule,对应 AnalyzeRule.getString 等公有方法) ──
|
||||
ruleStr := func(name string, args []goja.Value) (string, error) {
|
||||
if a == nil {
|
||||
return "", fmt.Errorf("无规则上下文")
|
||||
}
|
||||
mContent := any(nil)
|
||||
if len(args) > 1 && !goja.IsUndefined(args[1]) && !goja.IsNull(args[1]) {
|
||||
mContent = args[1].Export()
|
||||
}
|
||||
isURL := len(args) > 2 && args[2].ToBoolean()
|
||||
return a.GetString(args[0].String(), mContent, isURL)
|
||||
}
|
||||
set("getString", func(call goja.FunctionCall) goja.Value {
|
||||
s, err := ruleStr("getString", call.Arguments)
|
||||
if err != nil {
|
||||
bridgeErr("getString", err)
|
||||
}
|
||||
return vm.ToValue(s)
|
||||
})
|
||||
set("getStringList", func(call goja.FunctionCall) goja.Value {
|
||||
if a == nil {
|
||||
return goja.Null()
|
||||
}
|
||||
list, err := a.GetStringList(stringArgOr(call, 0, ""), nil, false)
|
||||
if err != nil {
|
||||
bridgeErr("getStringList", err)
|
||||
}
|
||||
return vm.ToValue(list)
|
||||
})
|
||||
set("getElements", func(call goja.FunctionCall) goja.Value {
|
||||
if a == nil {
|
||||
return goja.Null()
|
||||
}
|
||||
els, err := a.GetElements(stringArgOr(call, 0, ""))
|
||||
if err != nil {
|
||||
bridgeErr("getElements", err)
|
||||
}
|
||||
out := make([]any, 0, len(els))
|
||||
for _, el := range els {
|
||||
out = append(out, resultString(el))
|
||||
}
|
||||
return vm.ToValue(out)
|
||||
})
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := stringArgOr(call, 1, "")
|
||||
if a != nil {
|
||||
return vm.ToValue(a.Put(key, val))
|
||||
}
|
||||
r.vars[key] = val
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
|
||||
// ── 编码(对应 JsEncodeUtils / JsExtensions) ──
|
||||
set("md5Encode", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(md5Hex(stringArg(call, 0), false))
|
||||
})
|
||||
set("md5Encode16", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(md5Hex(stringArg(call, 0), true))
|
||||
})
|
||||
set("base64Decode", func(call goja.FunctionCall) goja.Value {
|
||||
s, err := base64DecodeString(stringArg(call, 0))
|
||||
if err != nil {
|
||||
bridgeErr("base64Decode", err)
|
||||
}
|
||||
return vm.ToValue(s)
|
||||
})
|
||||
set("base64DecodeToByteArray", func(call goja.FunctionCall) goja.Value {
|
||||
b, err := base64DecodeBytes(stringArg(call, 0))
|
||||
if err != nil {
|
||||
bridgeErr("base64DecodeToByteArray", err)
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(b))
|
||||
})
|
||||
set("base64Encode", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(base64.StdEncoding.EncodeToString([]byte(stringArg(call, 0))))
|
||||
})
|
||||
set("hexDecodeToString", func(call goja.FunctionCall) goja.Value {
|
||||
b, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
bridgeErr("hexDecodeToString", err)
|
||||
}
|
||||
return vm.ToValue(string(b))
|
||||
})
|
||||
set("hexDecodeToByteArray", func(call goja.FunctionCall) goja.Value {
|
||||
b, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
bridgeErr("hexDecodeToByteArray", err)
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(b))
|
||||
})
|
||||
set("hexEncodeToString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(hex.EncodeToString([]byte(stringArg(call, 0))))
|
||||
})
|
||||
set("strToBytes", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(vm.NewArrayBuffer([]byte(stringArg(call, 0))))
|
||||
})
|
||||
set("bytesToStr", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
if buf, ok := call.Arguments[0].Export().([]byte); ok {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
return vm.ToValue(call.Arguments[0].String())
|
||||
})
|
||||
set("encodeURI", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(javaURLEncode(stringArg(call, 0), stringArgOr(call, 1, "utf-8")))
|
||||
})
|
||||
set("timeFormat", func(call goja.FunctionCall) goja.Value {
|
||||
ts := int64(call.Arguments[0].ToFloat())
|
||||
format := stringArgOr(call, 1, "yyyy-MM-dd HH:mm")
|
||||
return vm.ToValue(javaTimeFormat(ts, format, 0))
|
||||
})
|
||||
set("timeFormatUTC", func(call goja.FunctionCall) goja.Value {
|
||||
ts := int64(call.Arguments[0].ToFloat())
|
||||
format := stringArgOr(call, 1, "yyyy-MM-dd HH:mm")
|
||||
sh := int64(0)
|
||||
if len(call.Arguments) > 2 {
|
||||
sh = int64(call.Arguments[2].ToFloat())
|
||||
}
|
||||
return vm.ToValue(javaTimeFormat(ts, format, sh))
|
||||
})
|
||||
|
||||
// ── 加解密(对应 JsEncodeUtils) ──
|
||||
set("createSymmetricCrypto", func(call goja.FunctionCall) goja.Value {
|
||||
transformation := stringArgOr(call, 0, "AES/CBC/PKCS5Padding")
|
||||
key := stringArgOr(call, 1, "")
|
||||
iv := stringArgOr(call, 2, "")
|
||||
cipher, err := newSymmetricCipher(transformation, key, iv)
|
||||
if err != nil {
|
||||
bridgeErr("createSymmetricCrypto", err)
|
||||
}
|
||||
return newCipherObject(vm, cipher)
|
||||
})
|
||||
aesVariant := func(mode string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
data := stringArg(call, 0)
|
||||
key := stringArgOr(call, 1, "")
|
||||
defaultTrans := mode
|
||||
trans := stringArgOr(call, 2, defaultTrans)
|
||||
iv := stringArgOr(call, 3, "")
|
||||
cipher, err := newSymmetricCipher(trans, key, iv)
|
||||
if err != nil {
|
||||
bridgeErr(mode, err)
|
||||
}
|
||||
out, err := cipher.decryptAuto(data)
|
||||
if err != nil {
|
||||
bridgeErr(mode, err)
|
||||
}
|
||||
return vm.ToValue(string(out))
|
||||
}
|
||||
}
|
||||
set("aesDecodeToString", aesVariant("AES/CBC/PKCS5Padding"))
|
||||
set("aesBase64DecodeToString", aesVariant("AES/CBC/PKCS5Padding"))
|
||||
set("aesBase64DecodeToByteArray", aesVariant("AES/CBC/PKCS5Padding"))
|
||||
set("desDecodeToString", aesVariant("DES/CBC/PKCS5Padding"))
|
||||
set("desBase64DecodeToString", aesVariant("DES/CBC/PKCS5Padding"))
|
||||
encryptStr := func(data, key, trans, iv string) string {
|
||||
cipher, err := newSymmetricCipher(trans, key, iv)
|
||||
if err != nil {
|
||||
bridgeErr("encrypt", err)
|
||||
}
|
||||
out, err := cipher.encrypt([]byte(data))
|
||||
if err != nil {
|
||||
bridgeErr("encrypt", err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
set("aesEncodeToString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(encryptStr(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "AES/CBC/PKCS5Padding"), stringArgOr(call, 3, "")))
|
||||
})
|
||||
set("desEncodeToString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(encryptStr(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "DES/CBC/PKCS5Padding"), stringArgOr(call, 3, "")))
|
||||
})
|
||||
encryptB64 := func(data, key, trans, iv string) string {
|
||||
cipher, err := newSymmetricCipher(trans, key, iv)
|
||||
if err != nil {
|
||||
bridgeErr("encrypt", err)
|
||||
}
|
||||
out, err := cipher.encrypt([]byte(data))
|
||||
if err != nil {
|
||||
bridgeErr("encrypt", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(out)
|
||||
}
|
||||
set("aesEncodeToBase64String", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(encryptB64(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "AES/CBC/PKCS5Padding"), stringArgOr(call, 3, "")))
|
||||
})
|
||||
set("desEncodeToBase64String", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(encryptB64(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "DES/CBC/PKCS5Padding"), stringArgOr(call, 3, "")))
|
||||
})
|
||||
set("tripleDESDecodeStr", func(call goja.FunctionCall) goja.Value {
|
||||
data := stringArg(call, 0)
|
||||
key := stringArgOr(call, 1, "")
|
||||
trans := "DESede/CBC/PKCS5Padding"
|
||||
var ivv string
|
||||
switch {
|
||||
case len(call.Arguments) > 4: // (data, key, mode, padding, iv)
|
||||
trans = "DESede/" + stringArgOr(call, 2, "CBC") + "/" + stringArgOr(call, 3, "PKCS5Padding")
|
||||
ivv = stringArgOr(call, 4, "")
|
||||
case len(call.Arguments) > 2: // (data, key, transformation, iv?)
|
||||
trans = stringArgOr(call, 2, trans)
|
||||
ivv = stringArgOr(call, 3, "")
|
||||
}
|
||||
cipher, err := newSymmetricCipher(trans, key, ivv)
|
||||
if err != nil {
|
||||
bridgeErr("tripleDESDecodeStr", err)
|
||||
}
|
||||
out, err := cipher.decryptAuto(data)
|
||||
if err != nil {
|
||||
bridgeErr("tripleDESDecodeStr", err)
|
||||
}
|
||||
return vm.ToValue(string(out))
|
||||
})
|
||||
set("digestHex", func(call goja.FunctionCall) goja.Value {
|
||||
h, err := digestHash(stringArgOr(call, 1, "MD5"))
|
||||
if err != nil {
|
||||
bridgeErr("digestHex", err)
|
||||
}
|
||||
h.Write([]byte(stringArg(call, 0)))
|
||||
return vm.ToValue(hex.EncodeToString(h.Sum(nil)))
|
||||
})
|
||||
set("digestBase64Str", func(call goja.FunctionCall) goja.Value {
|
||||
h, err := digestHash(stringArgOr(call, 1, "MD5"))
|
||||
if err != nil {
|
||||
bridgeErr("digestBase64Str", err)
|
||||
}
|
||||
h.Write([]byte(stringArg(call, 0)))
|
||||
return vm.ToValue(base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
})
|
||||
set("HMacHex", func(call goja.FunctionCall) goja.Value {
|
||||
h, err := hmacHash(stringArgOr(call, 1, "HmacSHA256"), []byte(stringArgOr(call, 2, "")))
|
||||
if err != nil {
|
||||
bridgeErr("HMacHex", err)
|
||||
}
|
||||
h.Write([]byte(stringArg(call, 0)))
|
||||
return vm.ToValue(hex.EncodeToString(h.Sum(nil)))
|
||||
})
|
||||
set("HMacBase64", func(call goja.FunctionCall) goja.Value {
|
||||
h, err := hmacHash(stringArgOr(call, 1, "HmacSHA256"), []byte(stringArgOr(call, 2, "")))
|
||||
if err != nil {
|
||||
bridgeErr("HMacBase64", err)
|
||||
}
|
||||
h.Write([]byte(stringArg(call, 0)))
|
||||
return vm.ToValue(base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
})
|
||||
|
||||
// ── 其他 ──
|
||||
set("log", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
r.cfg.Log(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(defaultWebViewUA)
|
||||
})
|
||||
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||
// 见 zh_convert.go 的说明。
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。
|
||||
set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) })
|
||||
// toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。
|
||||
set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) })
|
||||
// toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。
|
||||
set("toURL", func(call goja.FunctionCall) goja.Value {
|
||||
base := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
base = call.Arguments[1].String()
|
||||
}
|
||||
obj, err := newJSURLObject(vm, stringArg(call, 0), base)
|
||||
if err != nil {
|
||||
bridgeErr("toURL", err)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
// logType:调试用,打印值的类型(对应 legado 的日志实现)。
|
||||
set("logType", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) {
|
||||
r.cfg.Log("null")
|
||||
} else {
|
||||
r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export()))
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
|
||||
}
|
||||
|
||||
// ── 宿主交互:提示、浏览器页面、登录界面刷新 ──
|
||||
//
|
||||
// legado 用内置 WebView 承载页面:startBrowser / startBrowserAwait 打开一个
|
||||
// 页面让用户完成防爬校验、登录或参数选择,Await 版本还会把用户操作后的
|
||||
// 页面源码作为 StrResponse 返回。真实的聚合类书源(如光遇聚合)把「线路
|
||||
// 切换」「用户后台」「书源设置」全部建在这两个函数上,因此这里必须真正
|
||||
// 把页面交给前端,而不是记录个地址就算完。
|
||||
if r.state != nil {
|
||||
toast := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
r.state.Toast(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Null()
|
||||
}
|
||||
set("toast", toast)
|
||||
set("longToast", toast)
|
||||
|
||||
// startBrowser(url, title[, html]):展示页面,不等待用户完成。
|
||||
set("startBrowser", func(call goja.FunctionCall) goja.Value {
|
||||
if err := r.openBrowser(parseBrowserArgs(call)); err != nil {
|
||||
bridgeErr("startBrowser", err)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
// 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 {
|
||||
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 做"是否支持该环境"探测,
|
||||
// 成功返回会让源走安卓分支,这里统一以异常告知不支持并回退到通用分支。
|
||||
for _, name := range []string{"deviceID", "androidId"} {
|
||||
set(name, func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + ": 服务端无设备标识"))
|
||||
})
|
||||
}
|
||||
// 说明:下面这些名字在服务端刻意不定义,调用即抛异常,以对齐 legado 的
|
||||
// 真实可用面,避免「静默空实现」让书源误判运行环境:
|
||||
// qread / showReadingBrowser / startBrowserDp —— legado 中并不存在
|
||||
// (例如 checkEnv 用 java.qread() 探测「轻阅」,旧实现返回成功会让书源
|
||||
// 误认为运行在轻阅里,从而跳过它自己的降级分支)
|
||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
||||
// 服务端没有对应页面,谎报成功会让书源走错分支
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚
|
||||
// (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。
|
||||
unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason)))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
} {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
const defaultWebViewUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
|
||||
func md5Hex(s string, short bool) string {
|
||||
sum := md5.Sum([]byte(s))
|
||||
h := hex.EncodeToString(sum[:])
|
||||
if short {
|
||||
return h[8:24]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func digestHash(algorithm string) (hash.Hash, error) {
|
||||
switch strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(algorithm)) {
|
||||
case "MD5":
|
||||
return md5.New(), nil
|
||||
case "SHA1":
|
||||
return sha1.New(), nil
|
||||
case "SHA256":
|
||||
return sha256.New(), nil
|
||||
case "SHA384":
|
||||
return sha512.New384(), nil
|
||||
case "SHA512":
|
||||
return sha512.New(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的摘要算法: %s", algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func hmacHash(algorithm string, key []byte) (hash.Hash, error) {
|
||||
switch strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(algorithm)) {
|
||||
case "hmacmd5":
|
||||
return hmac.New(md5.New, key), nil
|
||||
case "hmacsha1":
|
||||
return hmac.New(sha1.New, key), nil
|
||||
case "hmacsha256":
|
||||
return hmac.New(sha256.New, key), nil
|
||||
case "hmacsha512":
|
||||
return hmac.New(sha512.New, key), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 HMAC 算法: %s", algorithm)
|
||||
}
|
||||
}
|
||||
@@ -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,285 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/des"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 对称加密:对应 legado JsEncodeUtils.createSymmetricCrypto(hutool SymmetricCrypto 语义)。
|
||||
// transformation 形如 "AES/CBC/PKCS5Padding" / "DES/ECB/NoPadding" / "DESede/CBC/PKCS5Padding"。
|
||||
|
||||
type symmetricCipher struct {
|
||||
block cipher.Block
|
||||
mode string
|
||||
padding string
|
||||
iv []byte
|
||||
}
|
||||
|
||||
func newSymmetricCipher(transformation, key, iv string) (*symmetricCipher, error) {
|
||||
parts := strings.Split(strings.TrimSpace(transformation), "/")
|
||||
algo := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||
mode := "CBC"
|
||||
padding := "PKCS5Padding"
|
||||
if len(parts) > 1 && strings.TrimSpace(parts[1]) != "" {
|
||||
mode = strings.ToUpper(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
if len(parts) > 2 && strings.TrimSpace(parts[2]) != "" {
|
||||
padding = strings.TrimSpace(parts[2])
|
||||
}
|
||||
|
||||
keyBytes := normalizeKey([]byte(key), algo)
|
||||
if len(keyBytes) == 0 {
|
||||
return nil, errors.New("加密密钥为空")
|
||||
}
|
||||
var block cipher.Block
|
||||
var err error
|
||||
switch algo {
|
||||
case "AES":
|
||||
block, err = aes.NewCipher(keyBytes)
|
||||
case "DES":
|
||||
block, err = des.NewCipher(keyBytes)
|
||||
case "DESEDE", "3DES", "TRIPLEDES":
|
||||
block, err = des.NewTripleDESCipher(keyBytes)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的加密算法: %s", algo)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ivBytes := []byte(iv)
|
||||
if mode == "ECB" {
|
||||
ivBytes = nil
|
||||
}
|
||||
return &symmetricCipher{block: block, mode: mode, padding: padding, iv: ivBytes}, nil
|
||||
}
|
||||
|
||||
// normalizeKey 对齐 hutool 的密钥处理:按算法要求补齐/截断密钥长度。
|
||||
func normalizeKey(key []byte, algo string) []byte {
|
||||
var sizes []int
|
||||
switch algo {
|
||||
case "AES":
|
||||
sizes = []int{16, 24, 32}
|
||||
case "DES":
|
||||
sizes = []int{8}
|
||||
case "DESEDE", "3DES", "TRIPLEDES":
|
||||
sizes = []int{24}
|
||||
default:
|
||||
return key
|
||||
}
|
||||
for _, n := range sizes {
|
||||
if len(key) == n {
|
||||
return key
|
||||
}
|
||||
}
|
||||
for _, n := range sizes {
|
||||
if len(key) < n {
|
||||
out := make([]byte, n)
|
||||
copy(out, key)
|
||||
return out
|
||||
}
|
||||
}
|
||||
return key[:sizes[len(sizes)-1]]
|
||||
}
|
||||
|
||||
func (c *symmetricCipher) blockSize() int { return c.block.BlockSize() }
|
||||
|
||||
func (c *symmetricCipher) crypt(dst, src []byte, decrypt bool) error {
|
||||
bs := c.blockSize()
|
||||
if c.mode == "ECB" {
|
||||
for i := 0; i < len(src); i += bs {
|
||||
if decrypt {
|
||||
c.block.Decrypt(dst[i:i+bs], src[i:i+bs])
|
||||
} else {
|
||||
c.block.Encrypt(dst[i:i+bs], src[i:i+bs])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
iv := make([]byte, bs)
|
||||
if len(c.iv) > 0 {
|
||||
copy(iv, c.iv[:min(len(c.iv), bs)])
|
||||
}
|
||||
switch c.mode {
|
||||
case "CBC":
|
||||
if decrypt {
|
||||
cipher.NewCBCDecrypter(c.block, iv).CryptBlocks(dst, src)
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(c.block, iv).CryptBlocks(dst, src)
|
||||
}
|
||||
case "CTR":
|
||||
cipher.NewCTR(c.block, iv).XORKeyStream(dst, src)
|
||||
case "OFB":
|
||||
cipher.NewOFB(c.block, iv).XORKeyStream(dst, src)
|
||||
case "CFB", "CFB8":
|
||||
stream := cipher.NewCFBEncrypter(c.block, iv)
|
||||
if decrypt {
|
||||
stream = cipher.NewCFBDecrypter(c.block, iv)
|
||||
}
|
||||
stream.XORKeyStream(dst, src)
|
||||
default:
|
||||
return fmt.Errorf("不支持的加密模式: %s", c.mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *symmetricCipher) encrypt(data []byte) ([]byte, error) {
|
||||
bs := c.blockSize()
|
||||
data = applyPadding(data, bs, c.padding)
|
||||
if len(data)%bs != 0 {
|
||||
return nil, errors.New("加密数据长度未按块对齐")
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
if err := c.crypt(out, data, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *symmetricCipher) decrypt(data []byte) ([]byte, error) {
|
||||
bs := c.blockSize()
|
||||
if len(data) == 0 || len(data)%bs != 0 {
|
||||
return nil, errors.New("密文长度不是块大小的整数倍")
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
if err := c.crypt(out, data, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stripPadding(out, c.padding), nil
|
||||
}
|
||||
|
||||
// decryptAuto 对应 hutool decryptStr(String):输入自动识别 Base64 / Hex / 原文。
|
||||
func (c *symmetricCipher) decryptAuto(s string) ([]byte, error) {
|
||||
if b, err := base64DecodeBytes(s); err == nil && len(b) > 0 && len(b)%c.blockSize() == 0 {
|
||||
return c.decrypt(b)
|
||||
}
|
||||
if b, err := hex.DecodeString(strings.TrimSpace(s)); err == nil && len(b) > 0 && len(b)%c.blockSize() == 0 {
|
||||
return c.decrypt(b)
|
||||
}
|
||||
return c.decrypt([]byte(s))
|
||||
}
|
||||
|
||||
func applyPadding(data []byte, blockSize int, padding string) []byte {
|
||||
switch padding {
|
||||
case "NoPadding":
|
||||
return data
|
||||
case "ZeroPadding":
|
||||
n := (len(data)+blockSize-1)/blockSize*blockSize
|
||||
if n == 0 {
|
||||
n = blockSize
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, data)
|
||||
return out
|
||||
default: // PKCS5Padding / PKCS7Padding
|
||||
pad := blockSize - len(data)%blockSize
|
||||
out := make([]byte, len(data)+pad)
|
||||
copy(out, data)
|
||||
for i := len(data); i < len(out); i++ {
|
||||
out[i] = byte(pad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func stripPadding(data []byte, padding string) []byte {
|
||||
switch padding {
|
||||
case "NoPadding":
|
||||
return data
|
||||
case "ZeroPadding":
|
||||
for len(data) > 0 && data[len(data)-1] == 0 {
|
||||
data = data[:len(data)-1]
|
||||
}
|
||||
return data
|
||||
default:
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
pad := int(data[len(data)-1])
|
||||
if pad <= 0 || pad > len(data) {
|
||||
return data
|
||||
}
|
||||
for _, b := range data[len(data)-pad:] {
|
||||
if int(b) != pad {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return data[:len(data)-pad]
|
||||
}
|
||||
}
|
||||
|
||||
// newCipherObject 对应 hutool SymmetricCrypto 的 JS 方法面:
|
||||
// encrypt(data) / encryptBase64(data) / encryptHex(data) / decrypt(data) /
|
||||
// decryptStr(data) / decryptBase64(data) / decryptHex(data)。
|
||||
func newCipherObject(vm *goja.Runtime, c *symmetricCipher) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
set("encrypt", func(call goja.FunctionCall) goja.Value {
|
||||
out, err := c.encrypt([]byte(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(out))
|
||||
})
|
||||
set("encryptBase64", func(call goja.FunctionCall) goja.Value {
|
||||
out, err := c.encrypt([]byte(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(base64StdEncodeBytes(out))
|
||||
})
|
||||
set("encryptHex", func(call goja.FunctionCall) goja.Value {
|
||||
out, err := c.encrypt([]byte(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(hexEncodeBytes(out))
|
||||
})
|
||||
set("decrypt", func(call goja.FunctionCall) goja.Value {
|
||||
out, err := c.decryptAuto(stringArg(call, 0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(out))
|
||||
})
|
||||
set("decryptStr", func(call goja.FunctionCall) goja.Value {
|
||||
out, err := c.decryptAuto(stringArg(call, 0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(string(out))
|
||||
})
|
||||
set("decryptBase64", func(call goja.FunctionCall) goja.Value {
|
||||
b, err := base64DecodeBytes(stringArg(call, 0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
out, err := c.decrypt(b)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(string(out))
|
||||
})
|
||||
set("decryptHex", func(call goja.FunctionCall) goja.Value {
|
||||
b, err := hexDecodeBytes(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
out, err := c.decrypt(b)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return vm.ToValue(string(out))
|
||||
})
|
||||
return o
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package rule 移植自 legado(refgd/legado,GPL-3.0)的规则引擎:
|
||||
// io.legado.app.model.analyzeRule 包下的 AnalyzeRule / AnalyzeByJSoup /
|
||||
// AnalyzeByJSonPath / AnalyzeByXPath / AnalyzeByRegex / AnalyzeUrl / RuleAnalyzer。
|
||||
// 语义以 legado 源码为基准逐项对齐,注释中标注了对应的 Kotlin 方法名。
|
||||
package rule
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrJsUnsupported 书源规则中包含 JS(<js>/@js:/{{}}/js 选项)。
|
||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||
)
|
||||
@@ -0,0 +1,512 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件对应 legado 的 Rhino JS 执行层(RhinoScriptEngine / evalJS),
|
||||
// 用纯 Go 的 goja 实现。`java` 对象的函数在 bridge.go 中以 Go 原生实现,
|
||||
// 函数名与 legado JsExtensions 保持一致(存量书源 JS 硬编码了这些名字)。
|
||||
|
||||
const (
|
||||
defaultJSTimeout = 10 * time.Second
|
||||
// programCacheSize 编译产物缓存上限(超出后整体清空,防无限增长)。
|
||||
programCacheSize = 256
|
||||
)
|
||||
|
||||
var (
|
||||
programCacheMu sync.Mutex
|
||||
programCache = map[string]*goja.Program{}
|
||||
)
|
||||
|
||||
func compileCached(js string) (*goja.Program, error) {
|
||||
programCacheMu.Lock()
|
||||
defer programCacheMu.Unlock()
|
||||
if p, ok := programCache[js]; ok {
|
||||
return p, nil
|
||||
}
|
||||
p, err := goja.Compile(fmt.Sprintf("<js:%d>", len(js)), js, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(programCache) >= programCacheSize {
|
||||
programCache = map[string]*goja.Program{}
|
||||
}
|
||||
programCache[js] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// JSFetcher 由服务层注入:桥接函数发起网络请求用(走 MeBox 的 HTTP 客户端,
|
||||
// 自动携带书源级请求头、UA、代理与重定向)。
|
||||
type JSFetcher func(req *Request) (body string, finalURL string, code int, err error)
|
||||
|
||||
// JSConfig 构造 JS 运行时的配置。
|
||||
type JSConfig struct {
|
||||
Fetch JSFetcher
|
||||
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样);
|
||||
// 同时供 source.getLoginInfoMap 从 loginUi 的 default 初始化登录信息。
|
||||
SourceProps map[string]any
|
||||
// Log 对应 java.log。
|
||||
Log func(msg string)
|
||||
// Timeout 单次 JS 执行超时,默认 10s。
|
||||
Timeout time.Duration
|
||||
// BaseURL 对应 evalJS 的 baseUrl 绑定。
|
||||
BaseURL string
|
||||
// Key / Page 搜索上下文绑定({{key}}/{{page}} 在 JS 里的取值)。
|
||||
Key string
|
||||
Page int
|
||||
// State 书源会话状态(变量/登录信息/Cookie)。nil 时用进程内 MemoryState。
|
||||
State SourceState
|
||||
// JSLib 书源 jsLib:在运行时创建后立即执行一次,
|
||||
// 其顶层函数与 lexical 绑定对该源后续所有 JS 可见(对应 legado SharedJsScope)。
|
||||
JSLib string
|
||||
// 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 一个实例)。
|
||||
type JSRunner struct {
|
||||
vm *goja.Runtime
|
||||
cfg JSConfig
|
||||
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
|
||||
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。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = defaultJSTimeout
|
||||
}
|
||||
state := cfg.State
|
||||
if state == nil {
|
||||
state = NewMemoryState()
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
}
|
||||
|
||||
// loadJSLib 执行书源 jsLib(一次),使其中定义的函数与 lexical 绑定
|
||||
// 对后续 Run 可见(goja 的顶层 let/const 与函数声明在同一 Runtime 内保持)。
|
||||
func (r *JSRunner) loadJSLib() {
|
||||
if strings.TrimSpace(r.cfg.JSLib) == "" {
|
||||
return
|
||||
}
|
||||
prog, err := compileCached(r.cfg.JSLib)
|
||||
if err != nil {
|
||||
r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// JSLibErr 返回 jsLib 的执行错误(nil 表示正常)。
|
||||
func (r *JSRunner) JSLibErr() error { return r.jsLibErr }
|
||||
|
||||
// State 返回运行时使用的会话状态。
|
||||
func (r *JSRunner) State() SourceState { return r.state }
|
||||
|
||||
// EvalAction 在已装载 jsLib 的环境中执行一段交互 JS(登录按钮 action 等)。
|
||||
// bindings 为附加的 JS 全局绑定(如 result)。返回脚本的返回值。
|
||||
//
|
||||
// 绑定集合与 Run 对齐(baseUrl/key/page),因为登录交互 JS 同样会用到它们。
|
||||
func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return nil, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
if r.cfg.Key != "" {
|
||||
vm.Set("key", r.cfg.Key)
|
||||
} else {
|
||||
vm.Set("key", nil)
|
||||
}
|
||||
if r.cfg.Page > 0 {
|
||||
vm.Set("page", r.cfg.Page)
|
||||
} else {
|
||||
vm.Set("page", nil)
|
||||
}
|
||||
for k, v := range bindings {
|
||||
vm.Set(k, toJSValue(vm, v))
|
||||
}
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return exportValue(v), nil
|
||||
}
|
||||
|
||||
// EvalLoginCheck 执行书源的 loginCheckJs(对应 legado WebBook 的 checkJs 钩子)。
|
||||
//
|
||||
// 语义对齐 legado:作用域里的 `result` 是一个响应对象(body()/code()/url()),
|
||||
// 脚本须返回响应对象或字符串;返回新 body 时调用方以之替换原响应体,
|
||||
// 从而支持「检测到会话失效 → 重新登录 → 重取页面」。
|
||||
func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (string, bool, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return "", false, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", newResponseObject(vm, body, code, finalURL, nil))
|
||||
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", false, nil
|
||||
}
|
||||
// 返回响应对象时取 body()(对应 legado 的 StrResponse)
|
||||
if obj, ok := v.(*goja.Object); ok {
|
||||
if fn, ok := goja.AssertFunction(obj.Get("body")); ok {
|
||||
res, err := fn(obj)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
|
||||
}
|
||||
return res.String(), true, nil
|
||||
}
|
||||
}
|
||||
return v.String(), true, nil
|
||||
}
|
||||
|
||||
|
||||
// ForAnalyzer 返回绑定到指定解析器的执行函数(java.getString 等规则回调
|
||||
// 会桥回该解析器,对应 legado 中 AnalyzeRule 自身实现 JsExtensions)。
|
||||
func (r *JSRunner) ForAnalyzer(a *AnalyzeRule) func(js string, result any) (any, error) {
|
||||
return func(js string, result any) (any, error) {
|
||||
return r.Run(a, js, result, "")
|
||||
}
|
||||
}
|
||||
|
||||
// RunWithBaseURL 与 Run 相同,但覆盖本次执行的 baseUrl 绑定(AnalyzeUrl 用)。
|
||||
func (r *JSRunner) RunWithBaseURL(a *AnalyzeRule, js string, result any, baseURL string) (any, error) {
|
||||
return r.Run(a, js, result, baseURL)
|
||||
}
|
||||
|
||||
// Run 执行一段书源 JS。绑定集合对应 legado evalJS:
|
||||
// java / cookie / cache / source / book / result / baseUrl / chapter / title /
|
||||
// src / page / key / nextChapterUrl。
|
||||
func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (any, error) {
|
||||
vm := r.vm
|
||||
// java 对象:每次执行重建(桥回当前解析器)
|
||||
r.installJava(a)
|
||||
// 上下文绑定
|
||||
if a != nil {
|
||||
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))
|
||||
} else {
|
||||
vm.Set("src", nil)
|
||||
}
|
||||
} else {
|
||||
vm.Set("book", nil)
|
||||
vm.Set("chapter", nil)
|
||||
vm.Set("title", nil)
|
||||
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
|
||||
}
|
||||
vm.Set("baseUrl", base)
|
||||
vm.Set("result", toJSValue(vm, result))
|
||||
if r.cfg.Key != "" {
|
||||
vm.Set("key", r.cfg.Key)
|
||||
} else {
|
||||
vm.Set("key", nil)
|
||||
}
|
||||
if r.cfg.Page > 0 {
|
||||
vm.Set("page", r.cfg.Page)
|
||||
} else {
|
||||
vm.Set("page", nil)
|
||||
}
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
switch v.ExportType() {
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return v.Export()
|
||||
}
|
||||
}
|
||||
|
||||
// toJSValue 把引擎内部结果转为可注入 JS 的值。
|
||||
// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。
|
||||
func toJSValue(vm *goja.Runtime, v any) any {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string, bool, int, int32, int64, float64, map[string]any, []any:
|
||||
return t
|
||||
// 登录表单等以 map[string]string 传入,交给 goja 直接反射转换
|
||||
case map[string]string:
|
||||
m := make(map[string]any, len(t))
|
||||
for k, v := range t {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
case []string:
|
||||
arr := make([]any, len(t))
|
||||
for i, s := range t {
|
||||
arr[i] = s
|
||||
}
|
||||
return arr
|
||||
case *goja.Object:
|
||||
return t
|
||||
case *goja.Runtime:
|
||||
return nil
|
||||
default:
|
||||
return resultString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// newResponseObject 构造 Connection.Response / StrResponse 的 JS 等价物:
|
||||
// body()/code()/url()/header(name)。
|
||||
func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string, headers map[string][]string) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
mustSet := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
|
||||
mustSet("bodyStr", body)
|
||||
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
|
||||
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
|
||||
mustSet("header", func(call goja.FunctionCall) goja.Value {
|
||||
name := strings.ToLower(strings.TrimSpace(toStringArg(call, 0)))
|
||||
for k, vs := range headers {
|
||||
if strings.ToLower(k) == name && len(vs) > 0 {
|
||||
return vm.ToValue(vs[0])
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
func toStringArg(call goja.FunctionCall, i int) string {
|
||||
if i >= len(call.Arguments) {
|
||||
return ""
|
||||
}
|
||||
return call.Arguments[i].String()
|
||||
}
|
||||
|
||||
func stringArg(call goja.FunctionCall, i int) string {
|
||||
return strings.TrimSpace(toStringArg(call, i))
|
||||
}
|
||||
|
||||
func stringArgOr(call goja.FunctionCall, i int, def string) string {
|
||||
if i >= len(call.Arguments) || goja.IsUndefined(call.Arguments[i]) || goja.IsNull(call.Arguments[i]) {
|
||||
return def
|
||||
}
|
||||
return call.Arguments[i].String()
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/cascadia"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 本文件在 golang.org/x/net/html 的 *html.Node 上实现 jsoup 的元素语义,
|
||||
// 供 jsoup 风格分析器使用。语义对齐 org.jsoup.nodes.Element。
|
||||
|
||||
func isElement(n *html.Node) bool {
|
||||
return n != nil && n.Type == html.ElementNode
|
||||
}
|
||||
|
||||
// childrenElements 对应 Element.children():直接子元素。
|
||||
func childrenElements(n *html.Node) []*html.Node {
|
||||
var out []*html.Node
|
||||
if n == nil {
|
||||
return out
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if isElement(c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectElements 对应 jsoup Collector.collect(evaluator, root):
|
||||
// 前序遍历,包含 root 自身。
|
||||
func collectElements(root *html.Node, pred func(*html.Node) bool) []*html.Node {
|
||||
var out []*html.Node
|
||||
if root == nil {
|
||||
return out
|
||||
}
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && pred(n) {
|
||||
out = append(out, n)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
// root 自身参与匹配(jsoup select/getElementsByXxx 均包含自身)
|
||||
if root.Type == html.ElementNode && pred(root) {
|
||||
out = append(out, root)
|
||||
}
|
||||
for c := root.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasClassToken(n *html.Node, class string) bool {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "class" {
|
||||
for _, tok := range strings.Fields(a.Val) {
|
||||
if tok == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getElementsByClass 对应 Element.getElementsByClass(含自身)。
|
||||
func getElementsByClass(root *html.Node, class string) []*html.Node {
|
||||
return collectElements(root, func(n *html.Node) bool { return hasClassToken(n, class) })
|
||||
}
|
||||
|
||||
// getElementsByTag 对应 Element.getElementsByTag(含自身)。
|
||||
func getElementsByTag(root *html.Node, tag string) []*html.Node {
|
||||
return collectElements(root, func(n *html.Node) bool { return n.Data == tag })
|
||||
}
|
||||
|
||||
// getElementsById 对应 Collector.collect(Evaluator.Id(id), root)(含自身)。
|
||||
func getElementsById(root *html.Node, id string) []*html.Node {
|
||||
return collectElements(root, func(n *html.Node) bool {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "id" && a.Val == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// getElementsContainingOwnText 对应 Evaluator.ContentsOwnText 语义:
|
||||
// ownText 包含目标串的元素。
|
||||
func getElementsContainingOwnText(root *html.Node, text string) []*html.Node {
|
||||
return collectElements(root, func(n *html.Node) bool {
|
||||
return strings.Contains(nodeOwnText(n), text)
|
||||
})
|
||||
}
|
||||
|
||||
// selectCSS 对应 Element.select(css):以 root 为起点(含自身)执行 CSS 选择。
|
||||
func selectCSS(root *html.Node, sel string) []*html.Node {
|
||||
compiled, err := cascadia.Compile(sel)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return selectWithCompiled(root, compiled)
|
||||
}
|
||||
|
||||
func selectWithCompiled(root *html.Node, sel cascadia.Selector) []*html.Node {
|
||||
var out []*html.Node
|
||||
if root.Type == html.ElementNode && sel(root) {
|
||||
out = append(out, root)
|
||||
}
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if isElement(c) && sel(c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeOwnText 对应 Element.ownText():直接子文本节点,规整空白后空格连接。
|
||||
func nodeOwnText(n *html.Node) string {
|
||||
var parts []string
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
t := normalizeWhitespace(c.Data)
|
||||
if t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// nodeText 对应 Element.text():全部后代文本规整空白(<br> 记空格,
|
||||
// 跳过 script/style),多段空白折叠为单个空格。
|
||||
func nodeText(n *html.Node) string {
|
||||
var sb bytes.Buffer
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
sb.WriteString(n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "br" {
|
||||
sb.WriteString(" ")
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return normalizeWhitespace(sb.String())
|
||||
}
|
||||
|
||||
// normalizeWhitespace 对应 jsoup TextUtil 的空白规整。
|
||||
func normalizeWhitespace(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// nodeTextNodes 对应 Element.textNodes():直接子文本节点(trim 非空)。
|
||||
func nodeTextNodes(n *html.Node) []string {
|
||||
var out []string
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
t := strings.TrimSpace(c.Data)
|
||||
if t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeData 对应 Element.data():script/style 的原始内容。
|
||||
func nodeData(n *html.Node) string {
|
||||
if n.Type != html.ElementNode || (n.Data != "script" && n.Data != "style") {
|
||||
return ""
|
||||
}
|
||||
var sb bytes.Buffer
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
sb.WriteString(c.Data)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// outerHTML 对应 Element.outerHtml()。
|
||||
func outerHTML(n *html.Node) string {
|
||||
var buf bytes.Buffer
|
||||
if err := html.Render(&buf, n); err != nil {
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// outerHTMLNoScript 对应 getResultLast "html" 分支:移除 script/style 后的 outerHtml。
|
||||
func outerHTMLNoScript(n *html.Node) string {
|
||||
clone := cloneNodeShallowTree(n)
|
||||
removeTags(clone, "script")
|
||||
removeTags(clone, "style")
|
||||
return outerHTML(clone)
|
||||
}
|
||||
|
||||
func removeTags(n *html.Node, tag string) {
|
||||
var toRemove []*html.Node
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if isElement(c) && c.Data == tag {
|
||||
toRemove = append(toRemove, c)
|
||||
}
|
||||
removeTags(c, tag)
|
||||
}
|
||||
for _, r := range toRemove {
|
||||
n.RemoveChild(r)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneNodeShallowTree 深拷贝节点树(html.Render 需要)。
|
||||
func cloneNodeShallowTree(n *html.Node) *html.Node {
|
||||
c := &html.Node{
|
||||
Type: n.Type,
|
||||
DataAtom: n.DataAtom,
|
||||
Data: n.Data,
|
||||
Attr: append([]html.Attribute(nil), n.Attr...),
|
||||
}
|
||||
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
cc := cloneNodeShallowTree(ch)
|
||||
c.AppendChild(cc)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func attrValue(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseHTML 对应 AnalyzeByJSoup.parse:字符串转节点树。
|
||||
// x/net/html 会补全 <html><body> 结构,选择器从 document 根开始匹配,
|
||||
// 与 jsoup 以 Document 为根的选择行为一致。
|
||||
func parseHTML(s string) *html.Node {
|
||||
nodes, err := html.Parse(strings.NewReader(s))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// 书源普遍用 legado 的内嵌模板拼字段(聚合源的 kind / 最新章节就是这种写法):
|
||||
//
|
||||
// kind = "{{$.status}},{{$.score}},{{$.tags}}"
|
||||
// lastChapter = "{{$.source}} {{$.last_chapter_title}}"
|
||||
//
|
||||
// 模板必须在**当前元素**上求值。修复前这里拿整份响应求值,模板一律取空,
|
||||
// 表现为 kind=",,,"、最新章节为空——搜索结果和换源列表里所有条目长得一模一样。
|
||||
func TestInnerRuleTemplateEvaluatesAgainstElement(t *testing.T) {
|
||||
const body = `{"data":[{"book_name":"全球高武","status":"连载","score":"9.2","tags":"都市","source":"svip_酷我","last_chapter_title":"第694章"}]}`
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(body, "")
|
||||
els, err := a.GetElements("$.data")
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("want 1 element, got %d", len(els))
|
||||
}
|
||||
el := els[0]
|
||||
|
||||
got, err := a.GetString("{{$.status}},{{$.score}},{{$.tags}}", el, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "连载,9.2,都市" {
|
||||
t.Fatalf("kind 模板 = %q, want 连载,9.2,都市", got)
|
||||
}
|
||||
|
||||
got, err = a.GetString("{{$.source}} {{$.last_chapter_title}}", el, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "svip_酷我 第694章" {
|
||||
t.Fatalf("最新章节模板 = %q, want svip_酷我 第694章", got)
|
||||
}
|
||||
|
||||
// 列表规则(GetStringList)走的是同一套 MakeUpRule,必须同样生效
|
||||
list, err := a.GetStringList("{{$.status}},{{$.score}}", el, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStringList: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0] != "连载,9.2" {
|
||||
t.Fatalf("列表模板 = %v, want [连载,9.2]", list)
|
||||
}
|
||||
|
||||
// 模板里取不存在的字段应得到空串,而不是报错
|
||||
got, err = a.GetString("[{{$.not_exist}}]", el, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "[]" {
|
||||
t.Fatalf("缺失字段 = %q, want []", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
|
||||
|
||||
func newTestRunner() *JSRunner {
|
||||
return NewJSRunner(JSConfig{Key: "斗", Page: 2})
|
||||
}
|
||||
|
||||
func TestJSBasicEval(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
v, err := r.Run(a, "1 + 2", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "3" {
|
||||
t.Fatalf("1+2 = %v", v)
|
||||
}
|
||||
// key/page 绑定
|
||||
v, err = r.Run(a, "key + page", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "斗2" {
|
||||
t.Fatalf("key+page = %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSTimeoutInterrupt(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{Timeout: 200 * 1e6}) // 200ms
|
||||
_, err := r.Run(NewAnalyzeRule(), "while(true){}", nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
|
||||
|
||||
func TestAnalyzeRuleJSEval(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("正文内容", "http://x.com")
|
||||
|
||||
// {{js}} 内嵌
|
||||
got, err := a.GetString(`{{baseUrl}}/next`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "http://x.com/next" {
|
||||
t.Fatalf("{{baseUrl}} = %q", got)
|
||||
}
|
||||
// @js: 前缀
|
||||
got, err = a.GetString(`@js:'hello ' + (40 + 2)`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "hello 42" {
|
||||
t.Fatalf("@js = %q", got)
|
||||
}
|
||||
// <js></js> 块
|
||||
got, err = a.GetString(`<js>"结果:" + result</js>`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "结果:正文内容" {
|
||||
t.Fatalf("<js> = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSGetStringBridge(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent(testHTML, "http://x.com")
|
||||
got, err := a.GetString(`<js>java.getString("class.item.0@tag.a@text")</js>`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "斗破苍穹" {
|
||||
t.Fatalf("java.getString = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 编码与摘要 ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestJSEncodeFunctions(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "") // 规则循环要求 content 非空(与 legado 语义一致)
|
||||
|
||||
cases := []struct{ js, want string }{
|
||||
{`@js:java.base64Encode('你好')`, base64.StdEncoding.EncodeToString([]byte("你好"))},
|
||||
{`@js:java.base64Decode('` + base64.StdEncoding.EncodeToString([]byte("你好")) + `')`, "你好"},
|
||||
{`@js:java.hexEncodeToString('AB')`, "4142"},
|
||||
{`@js:java.hexDecodeToString('4142')`, "AB"},
|
||||
{`@js:java.md5Encode('abc')`, "900150983cd24fb0d6963f7d28e17f72"},
|
||||
{`@js:java.md5Encode16('abc')`, "3cd24fb0d6963f7d"},
|
||||
{`@js:java.digestHex('abc','SHA-256')`, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := a.GetString(c.js, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.js, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.js, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 对称加解密 ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestJSSymmetricCryptoRoundTrip(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
|
||||
script := `
|
||||
var key = '0123456789abcdef';
|
||||
var iv = 'abcdef9876543210';
|
||||
var c = java.createSymmetricCrypto('AES/CBC/PKCS5Padding', key, iv);
|
||||
var enc = c.encryptBase64('测试明文内容');
|
||||
c.decryptStr(enc)
|
||||
`
|
||||
got, err := a.GetString(`@js:`+script, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "测试明文内容" {
|
||||
t.Fatalf("roundtrip = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESVector(t *testing.T) {
|
||||
// 用 Go 标准库生成固定密文,验证 decryptAuto + PKCS7 对齐
|
||||
block, _ := aes.NewCipher([]byte("0123456789abcdef"))
|
||||
iv := []byte("abcdef9876543210")
|
||||
plain := []byte("hello legado")
|
||||
padded := applyPadding(plain, block.BlockSize(), "PKCS5Padding")
|
||||
out := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
|
||||
ct := base64.StdEncoding.EncodeToString(out)
|
||||
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
got, err := a.GetString(`@js:java.aesBase64DecodeToString('`+ct+`', '0123456789abcdef', 'AES/CBC/PKCS5Padding', 'abcdef9876543210')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "hello legado" {
|
||||
t.Fatalf("aes decode = %q", got)
|
||||
}
|
||||
if _, err := hex.DecodeString("00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBCipher(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
script := `
|
||||
var c = java.createSymmetricCrypto('AES/ECB/PKCS5Padding', '0123456789abcdef', '');
|
||||
var enc = c.encryptBase64('ECB模式测试');
|
||||
c.decryptStr(enc)
|
||||
`
|
||||
got, err := a.GetString(`@js:`+script, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "ECB模式测试" {
|
||||
t.Fatalf("ecb roundtrip = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 网络桥(httptest) ─────────────────────────────────────────────────────
|
||||
|
||||
func TestJSAjaxBridge(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Token") != "tk" {
|
||||
http.Error(w, "no token", 401)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fetchCalled := false
|
||||
r := NewJSRunner(JSConfig{
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
fetchCalled = true
|
||||
// 经完整 ParseAnalyzeUrl 执行(headers 已在 req 上)
|
||||
resp, err := http.Get(req.URL)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 补上测试头
|
||||
req.Headers["X-Token"] = "tk"
|
||||
return `{"code":0,"data":"ok"}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
|
||||
got, err := a.GetString(`@js:java.get('`+srv.URL+`/api', {"X-Token":"tk"}).body()`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fetchCalled || !strings.Contains(got, `"ok"`) {
|
||||
t.Fatalf("ajax bridge = %q", got)
|
||||
}
|
||||
|
||||
// ajax 返回字符串
|
||||
got, err = a.GetString(`@js:java.ajax('`+srv.URL+`/api')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `"ok"`) {
|
||||
t.Fatalf("ajax = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── URL 规则 JS ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseAnalyzeUrlWithJSBlocks(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{Key: "斗罗", Page: 3})
|
||||
req, err := ParseAnalyzeUrlWithJS(`<js>'https://e.com/search?q=' + encodeURIComponent(key) + '&p=' + page</js>`, "斗罗", 3, "", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
t.Fatalf("unsupported: %v", req.Unsupported)
|
||||
}
|
||||
if !strings.Contains(req.URL, "p=3") || !strings.Contains(req.URL, "q=") {
|
||||
t.Fatalf("url = %q", req.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlJSTemplate(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{Key: "剑", Page: 2})
|
||||
req, err := ParseAnalyzeUrlWithJS("https://e.com/api?page={{page + 1}}&kw={{key}}", "剑", 2, "", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(req.URL, "page=3") || !strings.Contains(req.URL, "kw=%E5%89%91") {
|
||||
t.Fatalf("url = %q", req.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlBodyJs(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
// 与 legado 一致:option.js 里通过 result 引用当前 url
|
||||
req, err := ParseAnalyzeUrlWithJS(`https://e.com/x,{"bodyJs":"result + '!'","js":"result + '#anchor'"}`, "", 0, "", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(req.URL, "#anchor") {
|
||||
t.Fatalf("url = %q", req.URL)
|
||||
}
|
||||
if req.BodyJsFn == nil {
|
||||
t.Fatal("BodyJsFn should be set")
|
||||
}
|
||||
if got := req.BodyJsFn("body-x"); got != "body-x!" {
|
||||
t.Fatalf("bodyJs = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSUnsupportedStillWorks(t *testing.T) {
|
||||
// runner 为 nil 时,P0 行为保持:标记 Unsupported
|
||||
req, err := ParseAnalyzeUrl(`<js>'x'</js>`, "", 0, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Unsupported == nil {
|
||||
t.Fatal("expected unsupported without runner")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestJSSandboxUnsupported(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
_, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("expected sandbox error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSTimeFormatShape(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
got, err := a.GetString(`@js:java.timeFormat(1700000000000, 'yyyy-MM-dd HH:mm')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$`).MatchString(got) {
|
||||
t.Fatalf("timeFormat = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件:桥接层的小工具(编码字节转换、cookie/cache 对象、时间格式化)。
|
||||
|
||||
var b64EncodingVariants = []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
|
||||
// base64DecodeBytes 宽松解码:自动补 padding、支持 URL-safe 变体
|
||||
// (对应 legado Base64 解码的宽容行为)。
|
||||
func base64DecodeBytes(s string) ([]byte, error) {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
var lastErr error
|
||||
for _, enc := range b64EncodingVariants {
|
||||
b, err := enc.DecodeString(s)
|
||||
if err == nil {
|
||||
return b, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
// 缺 padding 的 std 变体
|
||||
if padded := s + strings.Repeat("=", (4-len(s)%4)%4); padded != s {
|
||||
if b, err := base64.StdEncoding.DecodeString(padded); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("无效 Base64")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func base64DecodeString(s string) (string, error) {
|
||||
b, err := base64DecodeBytes(s)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func hexDecodeBytes(s string) ([]byte, error) { return hex.DecodeString(s) }
|
||||
func base64StdEncodeBytes(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
func hexEncodeBytes(b []byte) string { return hex.EncodeToString(b) }
|
||||
|
||||
// ─── cookie 对象(对应 legado CookieStore 注入的 `cookie`) ─────────────────
|
||||
|
||||
// 会话状态由服务层注入(落库);未注入时用进程内 MemoryState。
|
||||
// 见 state.go —— 旧的全局 cookieJar 因无法按书源隔离且不落库已废弃。
|
||||
|
||||
// newCookieObject 构造 JS 的 `cookie` 对象:
|
||||
// getCookie(url[,key]) / setCookie(url,cookie) / replaceCookie / removeCookie / getKey。
|
||||
func newCookieObject(vm *goja.Runtime, state SourceState) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
set("getCookie", func(call goja.FunctionCall) goja.Value {
|
||||
tag := stringArg(call, 0)
|
||||
if len(call.Arguments) > 1 {
|
||||
return vm.ToValue(state.GetCookieKey(tag, stringArg(call, 1)))
|
||||
}
|
||||
return vm.ToValue(state.GetCookie(tag))
|
||||
})
|
||||
set("setCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
// replaceCookie 与 setCookie 在服务端实现中同为覆盖式合并(对应 legado 的语义)
|
||||
set("replaceCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
set("removeCookie", func(call goja.FunctionCall) goja.Value {
|
||||
state.RemoveCookie(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetCookieKey(stringArg(call, 0), stringArg(call, 1)))
|
||||
})
|
||||
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 {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
bindSourceState(vm, set, state, props)
|
||||
// source.get/put:书源级键值缓存(legado 中即 source 的方法)。
|
||||
// 注意 java.get/put 另有语义(网络 / 解析器变量),在 bridge.go 中定义,
|
||||
// 不在这里覆盖。
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(sourceKeyGet(state, stringArg(call, 0)))
|
||||
})
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
val := stringArgOr(call, 1, "")
|
||||
sourceKeyPut(state, stringArg(call, 0), val)
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
// 书源自身属性(bookSourceUrl 等)原样可读,存量 JS 会读 source.loginUi 等做能力探测。
|
||||
for k, v := range props {
|
||||
if o.Get(k) != nil {
|
||||
continue
|
||||
}
|
||||
_ = o.Set(k, v)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// bindSourceState 把书源会话状态方法绑定到目标 JS 对象。
|
||||
// legado 中 book source 的 evalJS 把 `java` 绑成书源对象自身,因此 `java` 与
|
||||
// `source` 都能读到 getVariable/getLoginInfo 等方法——这里保持一致。
|
||||
func bindSourceState(vm *goja.Runtime, set func(k string, v any), state SourceState, props map[string]any) {
|
||||
set("getVariable", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetVariable())
|
||||
})
|
||||
setVariable := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
state.SetVariable("")
|
||||
return goja.Null()
|
||||
}
|
||||
state.SetVariable(call.Arguments[0].String())
|
||||
return goja.Null()
|
||||
}
|
||||
set("setVariable", setVariable)
|
||||
set("putVariable", setVariable)
|
||||
|
||||
set("getLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginInfo(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
v := ""
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) && !goja.IsNull(call.Arguments[0]) {
|
||||
v = call.Arguments[0].String()
|
||||
}
|
||||
state.SetLoginInfo(v)
|
||||
return vm.ToValue(true)
|
||||
})
|
||||
set("removeLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginInfo("")
|
||||
return goja.Null()
|
||||
})
|
||||
// getLoginInfoMap 对应 Kotlin:解析登录信息 JSON;
|
||||
// 未设置且 loginUi 非空时,用 loginUi 里各字段的 default 初始化。
|
||||
set("getLoginInfoMap", func(call goja.FunctionCall) goja.Value {
|
||||
if m := parseLoginInfoMap(state.GetLoginInfo()); m != nil {
|
||||
return vm.ToValue(m)
|
||||
}
|
||||
return vm.ToValue(initLoginInfoFromUI(props))
|
||||
})
|
||||
|
||||
set("getLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginHeader(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
header := stringArgOr(call, 0, "")
|
||||
state.SetLoginHeader(header)
|
||||
// 请求头里的 Cookie 同步进 cookie 存储(对应 legado putLoginHeader)
|
||||
if header != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(header), &m) == nil {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
state.SetCookie(headerCookieURL(props), fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("removeLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginHeader("")
|
||||
state.RemoveCookie(headerCookieURL(props))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(headerCookieURL(props))
|
||||
})
|
||||
}
|
||||
|
||||
// sourceKeyGet / sourceKeyPut:source.get/put 的独立键值槽,
|
||||
// 以 "__kv__" 前缀编码进同一份变量 JSON,从而与 getVariable 一起持久化。
|
||||
const sourceKVVar = "__kv__"
|
||||
|
||||
func sourceKeyGet(state SourceState, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]string
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
return m[sourceKVVar+key]
|
||||
}
|
||||
|
||||
func sourceKeyPut(state SourceState, key, val string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
m := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
m[sourceKVVar+key] = val
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state.SetVariable(string(b))
|
||||
}
|
||||
|
||||
// headerCookieURL 取 loginHeader 里 Cookie 归属的 URL。
|
||||
func headerCookieURL(props map[string]any) string {
|
||||
if props != nil {
|
||||
if u, ok := props["bookSourceUrl"].(string); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseLoginInfoMap 解析登录信息 JSON;空串或非法 JSON 返回 nil。
|
||||
func parseLoginInfoMap(s string) map[string]string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(s), &raw) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
out[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initLoginInfoFromUI 用 loginUi 各字段 default 生成初始登录信息
|
||||
// (对应 legado getLoginInfoMap:跳过 button 类型)。
|
||||
func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw, _ := props["loginUi"].(string)
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var rows []map[string]any
|
||||
if json.Unmarshal([]byte(raw), &rows) == nil {
|
||||
for _, row := range rows {
|
||||
if t, _ := row["type"].(string); t == "button" {
|
||||
continue
|
||||
}
|
||||
name, _ := row["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
def, _ := row["default"].(string)
|
||||
out[name] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
|
||||
|
||||
var jsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(jsCache.m) >= 4096 {
|
||||
jsCache.m = map[string]string{}
|
||||
}
|
||||
jsCache.m[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := jsCache.m[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(v)
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(jsCache.m, key)
|
||||
jsCache.mu.Unlock()
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
// ─── 时间格式化(对应 JsExtensions.timeFormat/timeFormatUTC) ───────────────
|
||||
|
||||
// javaTimeFormat 将 Java SimpleDateFormat 常用 pattern 转 Go 布局。
|
||||
// sh 为时区偏移小时数(timeFormatUTC 语义,0 表示本地时区)。
|
||||
func javaTimeFormat(ts int64, format string, sh int64) string {
|
||||
loc := time.Local
|
||||
if sh != 0 {
|
||||
loc = time.FixedZone(fmt.Sprintf("UTC%+d", sh), int(sh)*3600)
|
||||
}
|
||||
t := time.UnixMilli(ts).In(loc)
|
||||
return t.Format(javaPatternToGo(format))
|
||||
}
|
||||
|
||||
func javaPatternToGo(p string) string {
|
||||
replacements := []struct{ java, goLayout string }{
|
||||
{"yyyy", "2006"}, {"yy", "06"},
|
||||
{"MM", "01"}, {"dd", "02"},
|
||||
{"HH", "15"}, {"hh", "03"},
|
||||
{"mm", "04"}, {"ss", "05"},
|
||||
{"SSS", "000"},
|
||||
{"a", "PM"},
|
||||
}
|
||||
// 优先替换长 token,避免 yyyy 被 yy+yy 拆坏
|
||||
for _, r := range replacements {
|
||||
p = strings.ReplaceAll(p, r.java, "\x00"+r.goLayout+"\x00")
|
||||
}
|
||||
return strings.NewReplacer("\x00", "").Replace(p)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeByJSonPath.kt(Jayway JsonPath 语义,Go 侧用
|
||||
// PaesslerAG/jsonpath 实现)。
|
||||
|
||||
type jsonAnalyzer struct {
|
||||
root any
|
||||
}
|
||||
|
||||
func newJSONAnalyzer(doc any) *jsonAnalyzer {
|
||||
switch t := doc.(type) {
|
||||
case string:
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(t), &v); err != nil {
|
||||
return &jsonAnalyzer{root: nil}
|
||||
}
|
||||
return &jsonAnalyzer{root: v}
|
||||
default:
|
||||
return &jsonAnalyzer{root: doc}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonRead 对应 ctx.read(rule):路径求值,失败返回 nil。
|
||||
func jsonRead(root any, path string) any {
|
||||
if root == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
v, err := jsonpathGet(path, root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = jsonValueToString(e)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// getString 对应 AnalyzeByJSonPath.getString。
|
||||
func (a *jsonAnalyzer) getString(rule string) string {
|
||||
if rule == "" {
|
||||
return ""
|
||||
}
|
||||
ra := NewRuleAnalyzer(rule, true)
|
||||
rules := ra.SplitRule("&&", "||")
|
||||
if len(rules) == 1 {
|
||||
ra.ReSetPos()
|
||||
result := ra.InnerRule("{$.", 1, 1, func(inner string) string {
|
||||
return a.getString(inner)
|
||||
})
|
||||
if result == "" {
|
||||
result = jsonValueToString(jsonRead(a.root, rule))
|
||||
}
|
||||
return result
|
||||
}
|
||||
var textList []string
|
||||
for _, rl := range rules {
|
||||
temp := a.getString(rl)
|
||||
if temp != "" {
|
||||
textList = append(textList, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(textList, "\n")
|
||||
}
|
||||
|
||||
// getStringList 对应 AnalyzeByJSonPath.getStringList。
|
||||
func (a *jsonAnalyzer) getStringList(rule string) []string {
|
||||
var result []string
|
||||
if rule == "" {
|
||||
return result
|
||||
}
|
||||
ra := NewRuleAnalyzer(rule, true)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) == 1 {
|
||||
ra.ReSetPos()
|
||||
st := ra.InnerRule("{$.", 1, 1, func(inner string) string {
|
||||
return a.getString(inner)
|
||||
})
|
||||
if st == "" {
|
||||
ob := jsonRead(a.root, rule)
|
||||
if ob == nil {
|
||||
return result
|
||||
}
|
||||
if lst, ok := ob.([]any); ok {
|
||||
for _, o := range lst {
|
||||
result = append(result, jsonValueToString(o))
|
||||
}
|
||||
} else {
|
||||
result = append(result, jsonValueToString(ob))
|
||||
}
|
||||
} else {
|
||||
result = append(result, st)
|
||||
}
|
||||
return result
|
||||
}
|
||||
var results [][]string
|
||||
for _, rl := range rules {
|
||||
temp := a.getStringList(rl)
|
||||
if len(temp) > 0 {
|
||||
results = append(results, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(results[0]); i++ {
|
||||
for _, temp := range results {
|
||||
if i < len(temp) {
|
||||
result = append(result, temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, temp := range results {
|
||||
result = append(result, temp...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getObject 对应 getObject:直接返回求值结果。
|
||||
func (a *jsonAnalyzer) getObject(rule string) any {
|
||||
return jsonRead(a.root, rule)
|
||||
}
|
||||
|
||||
// getList 对应 getList:要求路径结果为数组(对应 jayway read<ArrayList>,
|
||||
// 非数组时 legado 侧捕获异常返回空列表)。
|
||||
func (a *jsonAnalyzer) getList(rule string) []any {
|
||||
var result []any
|
||||
if rule == "" {
|
||||
return result
|
||||
}
|
||||
ra := NewRuleAnalyzer(rule, true)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) == 1 {
|
||||
ob := jsonRead(a.root, rules[0])
|
||||
if lst, ok := ob.([]any); ok {
|
||||
return lst
|
||||
}
|
||||
return result
|
||||
}
|
||||
var results [][]any
|
||||
for _, rl := range rules {
|
||||
temp := a.getList(rl)
|
||||
if len(temp) > 0 {
|
||||
results = append(results, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(results[0]); i++ {
|
||||
for _, temp := range results {
|
||||
if i < len(temp) {
|
||||
result = append(result, temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, temp := range results {
|
||||
result = append(result, temp...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeByJSoup.kt(含 ElementsSingle 索引语法)。
|
||||
|
||||
type jsoupAnalyzer struct {
|
||||
root *html.Node
|
||||
}
|
||||
|
||||
func newJsoupAnalyzer(doc any) *jsoupAnalyzer {
|
||||
return &jsoupAnalyzer{root: toHTMLNode(doc)}
|
||||
}
|
||||
|
||||
// toHTMLNode 对应 AnalyzeByJSoup.parse(doc):节点直通,字符串解析为 HTML。
|
||||
func toHTMLNode(doc any) *html.Node {
|
||||
switch t := doc.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case *html.Node:
|
||||
return t
|
||||
default:
|
||||
return parseHTML(anyToString(t))
|
||||
}
|
||||
}
|
||||
|
||||
// getStringList 对应 AnalyzeByJSoup.getStringList。
|
||||
func (a *jsoupAnalyzer) getStringList(ruleStr string) []string {
|
||||
var textS []string
|
||||
if ruleStr == "" {
|
||||
return textS
|
||||
}
|
||||
isCss := false
|
||||
elementsRule := ruleStr
|
||||
if hasPrefixFold(ruleStr, "@CSS:") {
|
||||
isCss = true
|
||||
elementsRule = strings.TrimSpace(ruleStr[5:])
|
||||
}
|
||||
|
||||
if elementsRule == "" {
|
||||
if d := nodeData(a.root); d != "" {
|
||||
textS = append(textS, d)
|
||||
} else {
|
||||
textS = append(textS, "")
|
||||
}
|
||||
return textS
|
||||
}
|
||||
|
||||
ra := NewRuleAnalyzer(elementsRule, false)
|
||||
ruleStrS := ra.SplitRule("&&", "||", "%%")
|
||||
|
||||
var results [][]string
|
||||
for _, ruleStrX := range ruleStrS {
|
||||
var temp []string
|
||||
if isCss {
|
||||
// 对应 isCss 分支:lastIndexOf('@') 分离选择器与提取规则
|
||||
lastIndex := strings.LastIndex(ruleStrX, "@")
|
||||
var elements []*html.Node
|
||||
var lastRule string
|
||||
if lastIndex == -1 {
|
||||
elements = selectCSS(a.root, ruleStrX)
|
||||
lastRule = "text"
|
||||
} else {
|
||||
selector := ruleStrX[:lastIndex]
|
||||
if strings.TrimSpace(selector) == "" {
|
||||
elements = []*html.Node{a.root}
|
||||
} else {
|
||||
elements = selectCSS(a.root, selector)
|
||||
}
|
||||
lastRule = ruleStrX[lastIndex+1:]
|
||||
}
|
||||
temp = getResultLast(elements, lastRule)
|
||||
} else {
|
||||
temp = a.getResultList(ruleStrX)
|
||||
}
|
||||
if len(temp) > 0 {
|
||||
results = append(results, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(results[0]); i++ {
|
||||
for _, temp := range results {
|
||||
if i < len(temp) {
|
||||
textS = append(textS, temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, temp := range results {
|
||||
textS = append(textS, temp...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return textS
|
||||
}
|
||||
|
||||
// getString 对应 getString:多结果以 \n 连接。
|
||||
func (a *jsoupAnalyzer) getString(ruleStr string) string {
|
||||
list := a.getStringList(ruleStr)
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(list) == 1 {
|
||||
return list[0]
|
||||
}
|
||||
return strings.Join(list, "\n")
|
||||
}
|
||||
|
||||
// getString0 对应 getString0:只取第一个。
|
||||
func (a *jsoupAnalyzer) getString0(ruleStr string) string {
|
||||
list := a.getStringList(ruleStr)
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// getElements 对应 getElements。
|
||||
func (a *jsoupAnalyzer) getElements(rule string) []*html.Node {
|
||||
if rule == "" || a.root == nil {
|
||||
return nil
|
||||
}
|
||||
isCss := false
|
||||
elementsRule := rule
|
||||
if hasPrefixFold(rule, "@CSS:") {
|
||||
isCss = true
|
||||
elementsRule = strings.TrimSpace(rule[5:])
|
||||
}
|
||||
ra := NewRuleAnalyzer(elementsRule, false)
|
||||
ruleStrS := ra.SplitRule("&&", "||", "%%")
|
||||
|
||||
var elementsList [][]*html.Node
|
||||
if isCss {
|
||||
for _, ruleStr := range ruleStrS {
|
||||
tempS := selectCSS(a.root, ruleStr)
|
||||
elementsList = append(elementsList, tempS)
|
||||
if len(tempS) > 0 && ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, ruleStr := range ruleStrS {
|
||||
rsRule := NewRuleAnalyzer(ruleStr, false)
|
||||
rsRule.Trim()
|
||||
rs := rsRule.SplitRule("@")
|
||||
var el []*html.Node
|
||||
if len(rs) > 1 {
|
||||
el = []*html.Node{a.root}
|
||||
for _, rl := range rs {
|
||||
var es []*html.Node
|
||||
for _, et := range el {
|
||||
es = append(es, a.getElementsOf(et, rl)...)
|
||||
}
|
||||
el = es
|
||||
}
|
||||
} else {
|
||||
es := newElementsSingle().getElementsSingle(a.root, ruleStr)
|
||||
el = es
|
||||
}
|
||||
elementsList = append(elementsList, el)
|
||||
if len(el) > 0 && ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var elements []*html.Node
|
||||
if len(elementsList) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(elementsList[0]); i++ {
|
||||
for _, es := range elementsList {
|
||||
if i < len(es) {
|
||||
elements = append(elements, es[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, es := range elementsList {
|
||||
elements = append(elements, es...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// getElementsOf 对应私有 getElements(temp, rule) 递归。
|
||||
func (a *jsoupAnalyzer) getElementsOf(temp *html.Node, rule string) []*html.Node {
|
||||
if temp == nil || rule == "" {
|
||||
return nil
|
||||
}
|
||||
isCss := false
|
||||
elementsRule := rule
|
||||
if hasPrefixFold(rule, "@CSS:") {
|
||||
isCss = true
|
||||
elementsRule = strings.TrimSpace(rule[5:])
|
||||
}
|
||||
ra := NewRuleAnalyzer(elementsRule, false)
|
||||
ruleStrS := ra.SplitRule("&&", "||", "%%")
|
||||
|
||||
var elementsList [][]*html.Node
|
||||
if isCss {
|
||||
for _, ruleStr := range ruleStrS {
|
||||
tempS := selectCSS(temp, ruleStr)
|
||||
elementsList = append(elementsList, tempS)
|
||||
if len(tempS) > 0 && ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, ruleStr := range ruleStrS {
|
||||
rsRule := NewRuleAnalyzer(ruleStr, false)
|
||||
rsRule.Trim()
|
||||
rs := rsRule.SplitRule("@")
|
||||
var el []*html.Node
|
||||
if len(rs) > 1 {
|
||||
el = []*html.Node{temp}
|
||||
for _, rl := range rs {
|
||||
var es []*html.Node
|
||||
for _, et := range el {
|
||||
es = append(es, a.getElementsOf(et, rl)...)
|
||||
}
|
||||
el = es
|
||||
}
|
||||
} else {
|
||||
el = newElementsSingle().getElementsSingle(temp, ruleStr)
|
||||
}
|
||||
elementsList = append(elementsList, el)
|
||||
if len(el) > 0 && ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var elements []*html.Node
|
||||
if len(elementsList) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(elementsList[0]); i++ {
|
||||
for _, es := range elementsList {
|
||||
if i < len(es) {
|
||||
elements = append(elements, es[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, es := range elementsList {
|
||||
elements = append(elements, es...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// getResultList 对应 getResultList:按 "@" 步进,最后一段作为提取规则。
|
||||
func (a *jsoupAnalyzer) getResultList(ruleStr string) []string {
|
||||
if ruleStr == "" {
|
||||
return nil
|
||||
}
|
||||
elements := []*html.Node{a.root}
|
||||
rule := NewRuleAnalyzer(ruleStr, false)
|
||||
rule.Trim()
|
||||
rules := rule.SplitRule("@")
|
||||
last := len(rules) - 1
|
||||
for i := 0; i < last; i++ {
|
||||
var es []*html.Node
|
||||
for _, elt := range elements {
|
||||
es = append(es, newElementsSingle().getElementsSingle(elt, rules[i])...)
|
||||
}
|
||||
elements = es
|
||||
}
|
||||
if len(elements) == 0 {
|
||||
return nil
|
||||
}
|
||||
return getResultLast(elements, rules[last])
|
||||
}
|
||||
|
||||
// getResultLast 对应 getResultLast:按最后一个规则提取内容。
|
||||
func getResultLast(elements []*html.Node, lastRule string) []string {
|
||||
var textS []string
|
||||
switch lastRule {
|
||||
case "text":
|
||||
for _, element := range elements {
|
||||
if text := nodeText(element); text != "" {
|
||||
textS = append(textS, text)
|
||||
}
|
||||
}
|
||||
case "textNodes":
|
||||
for _, element := range elements {
|
||||
tn := nodeTextNodes(element)
|
||||
if len(tn) > 0 {
|
||||
textS = append(textS, strings.Join(tn, "\n"))
|
||||
}
|
||||
}
|
||||
case "ownText":
|
||||
for _, element := range elements {
|
||||
if text := nodeOwnText(element); text != "" {
|
||||
textS = append(textS, text)
|
||||
}
|
||||
}
|
||||
case "html":
|
||||
for _, element := range elements {
|
||||
if h := outerHTMLNoScript(element); h != "" {
|
||||
textS = append(textS, h)
|
||||
}
|
||||
}
|
||||
case "all":
|
||||
var sb strings.Builder
|
||||
for _, element := range elements {
|
||||
sb.WriteString(outerHTML(element))
|
||||
}
|
||||
textS = append(textS, sb.String())
|
||||
default:
|
||||
for _, element := range elements {
|
||||
url := attrValue(element, lastRule)
|
||||
if url == "" || containsStr(textS, url) {
|
||||
continue
|
||||
}
|
||||
textS = append(textS, url)
|
||||
}
|
||||
}
|
||||
return textS
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── ElementsSingle:索引语法(对应 data class ElementsSingle) ─────────────
|
||||
|
||||
type indexRange struct {
|
||||
start *int
|
||||
end *int
|
||||
step int
|
||||
}
|
||||
|
||||
type elementsSingle struct {
|
||||
split byte // '.' 选择 / '!' 排除 / ' ' 无索引
|
||||
beforeRule string
|
||||
indexDefault []int
|
||||
indexes []any // int 或 indexRange
|
||||
}
|
||||
|
||||
func newElementsSingle() *elementsSingle {
|
||||
return &elementsSingle{split: '.'}
|
||||
}
|
||||
|
||||
func (e *elementsSingle) getElementsSingle(temp *html.Node, rule string) []*html.Node {
|
||||
e.findIndexSet(rule)
|
||||
|
||||
var elements []*html.Node
|
||||
if e.beforeRule == "" {
|
||||
elements = childrenElements(temp)
|
||||
} else {
|
||||
rules := strings.Split(e.beforeRule, ".")
|
||||
arg := ""
|
||||
if len(rules) > 1 {
|
||||
arg = rules[1]
|
||||
}
|
||||
switch rules[0] {
|
||||
case "children":
|
||||
elements = childrenElements(temp)
|
||||
case "class":
|
||||
if arg != "" {
|
||||
elements = getElementsByClass(temp, arg)
|
||||
}
|
||||
case "tag":
|
||||
if arg != "" {
|
||||
elements = getElementsByTag(temp, arg)
|
||||
}
|
||||
case "id":
|
||||
if arg != "" {
|
||||
elements = getElementsById(temp, arg)
|
||||
}
|
||||
case "text":
|
||||
if arg != "" {
|
||||
elements = getElementsContainingOwnText(temp, arg)
|
||||
}
|
||||
default:
|
||||
elements = selectCSS(temp, e.beforeRule)
|
||||
}
|
||||
}
|
||||
|
||||
// 索引集合:slice+set 模拟 Kotlin LinkedHashSet 的插入顺序去重
|
||||
indexSet := make([]int, 0, len(elements))
|
||||
seen := make(map[int]bool)
|
||||
addIndex := func(ix int) {
|
||||
if !seen[ix] {
|
||||
seen[ix] = true
|
||||
indexSet = append(indexSet, ix)
|
||||
}
|
||||
}
|
||||
|
||||
lenn := len(elements)
|
||||
lastIndexes := -1
|
||||
if len(e.indexDefault) > 0 {
|
||||
lastIndexes = len(e.indexDefault) - 1
|
||||
} else if len(e.indexes) > 0 {
|
||||
lastIndexes = len(e.indexes) - 1
|
||||
}
|
||||
|
||||
if len(e.indexes) == 0 {
|
||||
// 旧式索引:逆向遍历还原顺序
|
||||
for ix := lastIndexes; ix >= 0; ix-- {
|
||||
it := e.indexDefault[ix]
|
||||
if it >= 0 && it < lenn {
|
||||
addIndex(it)
|
||||
} else if it < 0 && lenn >= -it {
|
||||
addIndex(it + lenn)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for ix := lastIndexes; ix >= 0; ix-- {
|
||||
if rg, ok := e.indexes[ix].(indexRange); ok {
|
||||
start := 0
|
||||
if rg.start != nil {
|
||||
start = *rg.start
|
||||
}
|
||||
if start < 0 {
|
||||
start += lenn
|
||||
}
|
||||
end := lenn - 1
|
||||
if rg.end != nil {
|
||||
end = *rg.end
|
||||
}
|
||||
if end < 0 {
|
||||
end += lenn
|
||||
}
|
||||
if (start < 0 && end < 0) || (start >= lenn && end >= lenn) {
|
||||
continue
|
||||
}
|
||||
if start >= lenn {
|
||||
start = lenn - 1
|
||||
} else if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end >= lenn {
|
||||
end = lenn - 1
|
||||
} else if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
if start == end || rg.step >= lenn {
|
||||
addIndex(start)
|
||||
continue
|
||||
}
|
||||
step := rg.step
|
||||
if step > 0 {
|
||||
// 正向步长原样使用
|
||||
} else if -step < lenn {
|
||||
step = step + lenn
|
||||
} else {
|
||||
step = 1
|
||||
}
|
||||
if end > start {
|
||||
for i := start; i <= end; i += step {
|
||||
addIndex(i)
|
||||
}
|
||||
} else {
|
||||
for i := start; i >= end; i -= step {
|
||||
addIndex(i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
it := e.indexes[ix].(int)
|
||||
if it >= 0 && it < lenn {
|
||||
addIndex(it)
|
||||
} else if it < 0 && lenn >= -it {
|
||||
addIndex(it + lenn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.split == '!' {
|
||||
exclude := make(map[int]bool)
|
||||
for _, ix := range indexSet {
|
||||
exclude[ix] = true
|
||||
}
|
||||
var es []*html.Node
|
||||
for i, el := range elements {
|
||||
if !exclude[i] {
|
||||
es = append(es, el)
|
||||
}
|
||||
}
|
||||
elements = es
|
||||
} else if e.split == '.' {
|
||||
var es []*html.Node
|
||||
for _, ix := range indexSet {
|
||||
if ix >= 0 && ix < lenn {
|
||||
es = append(es, elements[ix])
|
||||
}
|
||||
}
|
||||
elements = es
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// findIndexSet 对应 ElementsSingle.findIndexSet:从右向左解析索引。
|
||||
// 支持旧式 tag.div.-1:10:2 / tag.div!0:3 与新式 tag.div[!-1, 3:-2:-10, 2]。
|
||||
func (e *elementsSingle) findIndexSet(rule string) {
|
||||
rus := []rune(strings.TrimSpace(rule))
|
||||
n := len(rus)
|
||||
if n == 0 {
|
||||
e.split = ' '
|
||||
e.beforeRule = ""
|
||||
return
|
||||
}
|
||||
var curList []*int // 区间临时列表(逆向压入:右端、左端、间隔)
|
||||
l := "" // 暂存数字字符串
|
||||
curMinus := false
|
||||
|
||||
head := rus[n-1] == ']'
|
||||
length := n
|
||||
if head {
|
||||
length-- // 跳过尾部 ']'
|
||||
}
|
||||
findLoop:
|
||||
for length >= 0 {
|
||||
length--
|
||||
if length < 0 {
|
||||
break
|
||||
}
|
||||
rl := rus[length]
|
||||
if rl == ' ' {
|
||||
continue
|
||||
}
|
||||
if rl >= '0' && rl <= '9' {
|
||||
l = string(rl) + l
|
||||
continue
|
||||
}
|
||||
if rl == '-' {
|
||||
curMinus = true
|
||||
continue
|
||||
}
|
||||
var curInt *int
|
||||
if l != "" {
|
||||
v := parseIntSafe(l)
|
||||
if curMinus {
|
||||
v = -v
|
||||
}
|
||||
curInt = &v
|
||||
}
|
||||
if head {
|
||||
switch rl {
|
||||
case ':':
|
||||
curList = append(curList, curInt)
|
||||
default:
|
||||
if len(curList) == 0 {
|
||||
if curInt == nil {
|
||||
break findLoop // 是 jsoup 选择器而非索引列表
|
||||
}
|
||||
e.indexes = append(e.indexes, *curInt)
|
||||
} else {
|
||||
rg := indexRange{start: curInt, end: curList[len(curList)-1], step: 1}
|
||||
if len(curList) == 2 && curList[0] != nil {
|
||||
rg.step = *curList[0]
|
||||
}
|
||||
e.indexes = append(e.indexes, rg)
|
||||
curList = curList[:0]
|
||||
}
|
||||
if rl == '!' {
|
||||
e.split = '!'
|
||||
for {
|
||||
length--
|
||||
if length < 0 {
|
||||
break
|
||||
}
|
||||
rl = rus[length]
|
||||
if rl != ' ' {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rl == '[' {
|
||||
if length < 0 {
|
||||
length = 0
|
||||
}
|
||||
e.beforeRule = string(rus[:length])
|
||||
return
|
||||
}
|
||||
if rl != ',' {
|
||||
break findLoop
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if rl == '!' || rl == '.' || rl == ':' {
|
||||
v := 0
|
||||
if curInt != nil {
|
||||
v = *curInt
|
||||
}
|
||||
e.indexDefault = append(e.indexDefault, v)
|
||||
if rl != ':' {
|
||||
e.split = byte(rl)
|
||||
e.beforeRule = string(rus[:length])
|
||||
return
|
||||
}
|
||||
} else {
|
||||
break findLoop
|
||||
}
|
||||
}
|
||||
l = ""
|
||||
curMinus = false
|
||||
}
|
||||
e.split = ' '
|
||||
e.beforeRule = string(rus)
|
||||
}
|
||||
|
||||
func parseIntSafe(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
// 本文件对应 legado 的 NetworkUtils.kt / EncoderUtils.kt 中与规则引擎相关的函数。
|
||||
|
||||
// GetAbsoluteURL 对应 NetworkUtils.getAbsoluteURL(baseURL: String?, relativePath)。
|
||||
// baseURL 会先截掉 ",{...}" 选项段(substringBefore(","))。
|
||||
func GetAbsoluteURL(baseURL, relativePath string) string {
|
||||
rel := strings.TrimSpace(relativePath)
|
||||
if isAbsURL(rel) || isDataURL(rel) || strings.HasPrefix(rel, "javascript") {
|
||||
if strings.HasPrefix(rel, "javascript") {
|
||||
return ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
if baseURL == "" || isDataURL(baseURL) {
|
||||
return rel
|
||||
}
|
||||
base := baseURL
|
||||
if i := strings.Index(base, ","); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
baseURLParsed, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil {
|
||||
return rel
|
||||
}
|
||||
return GetAbsoluteURLParsed(baseURLParsed, rel)
|
||||
}
|
||||
|
||||
// GetAbsoluteURLParsed 对应 NetworkUtils.getAbsoluteURL(baseURL: URL?, relativePath)。
|
||||
func GetAbsoluteURLParsed(base *url.URL, relativePath string) string {
|
||||
rel := strings.TrimSpace(relativePath)
|
||||
if base == nil {
|
||||
return rel
|
||||
}
|
||||
if isAbsURL(rel) || isDataURL(rel) {
|
||||
return rel
|
||||
}
|
||||
if strings.HasPrefix(rel, "javascript") {
|
||||
return ""
|
||||
}
|
||||
ref, err := url.Parse(rel)
|
||||
if err != nil {
|
||||
return rel
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
// GetBaseUrl 对应 NetworkUtils.getBaseUrl:scheme://host[:port]。
|
||||
func GetBaseUrl(u string) string {
|
||||
if len(u) >= 8 && (strings.EqualFold(u[:7], "http://") || (len(u) >= 9 && strings.EqualFold(u[:8], "https://"))) {
|
||||
if idx := strings.Index(u[8:], "/"); idx >= 0 {
|
||||
return u[:8+idx]
|
||||
}
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isAbsURL 对应 String.isAbsUrl()。
|
||||
func isAbsURL(s string) bool {
|
||||
return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://")
|
||||
}
|
||||
|
||||
func isDataURL(s string) bool {
|
||||
return strings.HasPrefix(s, "data:")
|
||||
}
|
||||
|
||||
// notNeedEncodingQuery / notNeedEncodingForm 对应 NetworkUtils 的两个 BitSet。
|
||||
var (
|
||||
notNeedEncodingQuery = buildEncodingSet("!$&()*+,-./:;=?@[\\]^_`{|}~")
|
||||
notNeedEncodingForm = buildEncodingSet("*-._")
|
||||
)
|
||||
|
||||
func buildEncodingSet(extra string) map[rune]bool {
|
||||
set := make(map[rune]bool, 128)
|
||||
for r := 'a'; r <= 'z'; r++ {
|
||||
set[r] = true
|
||||
}
|
||||
for r := 'A'; r <= 'Z'; r++ {
|
||||
set[r] = true
|
||||
}
|
||||
for r := '0'; r <= '9'; r++ {
|
||||
set[r] = true
|
||||
}
|
||||
for _, r := range extra {
|
||||
set[r] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func isDigit16(r byte) bool {
|
||||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
}
|
||||
|
||||
// encodedQuery 对应 NetworkUtils.encodedQuery(str):判断字符串是否已按
|
||||
// urlEncode 规范编码(无需再编码返回 true)。
|
||||
func encodedQuery(s string) bool {
|
||||
return encodedWith(s, notNeedEncodingQuery)
|
||||
}
|
||||
|
||||
// encodedForm 对应 NetworkUtils.encodedForm(str)。
|
||||
func encodedForm(s string) bool {
|
||||
return encodedWith(s, notNeedEncodingForm)
|
||||
}
|
||||
|
||||
func encodedWith(s string, allow map[rune]bool) bool {
|
||||
rs := []rune(s)
|
||||
for i := 0; i < len(rs); i++ {
|
||||
r := rs[i]
|
||||
if r < 128 && allow[r] {
|
||||
continue
|
||||
}
|
||||
if r == '%' && i+2 < len(rs) && isDigit16(byte(rs[i+1])) && isDigit16(byte(rs[i+2])) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return len(rs) > 0
|
||||
}
|
||||
|
||||
// JSEscape 对应 EncoderUtils.escape(JS escape() 语义)。
|
||||
func JSEscape(src string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range src {
|
||||
code := int(r)
|
||||
if (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) {
|
||||
sb.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case code < 16:
|
||||
fmt.Fprintf(&sb, "%%0%X", code)
|
||||
case code < 256:
|
||||
fmt.Fprintf(&sb, "%%%X", code)
|
||||
default:
|
||||
fmt.Fprintf(&sb, "%%u%04X", code)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DecodeBytes 按字符集名解码字节流,name 为空时按 UTF-8(带 BOM 处理)。
|
||||
func DecodeBytes(b []byte, name string) (string, error) {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return string(b[3:]), nil
|
||||
}
|
||||
if name == "" || strings.EqualFold(name, "utf-8") || strings.EqualFold(name, "utf8") {
|
||||
dec := unicode.UTF8.NewDecoder()
|
||||
out, err := dec.Bytes(b)
|
||||
if err != nil {
|
||||
return string(b), nil
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
enc, err := htmlindex.Get(name)
|
||||
if err != nil {
|
||||
return string(b), nil
|
||||
}
|
||||
out, derr := enc.NewDecoder().Bytes(b)
|
||||
if derr != nil {
|
||||
return string(b), nil
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// LooksLikeJSON 对应 String.isJson()(宽松:trim 后以 { 或 [ 开头)。
|
||||
func LooksLikeJSON(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeByRegex.kt。Java 正则语义用 regexp2 对齐
|
||||
// (支持前向后向断言与反向引用),匹配循环对齐 Matcher.find()。
|
||||
|
||||
// splitNotBlankAndTrim 对应 String.splitNotBlank("&&"):切分并去空白项。
|
||||
func splitNotBlankAndTrim(s, sep string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, sep) {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// regexGetElement 对应 AnalyzeByRegex.getElement:多段正则串联,
|
||||
// 最终返回第一个匹配的全部分组(含 group 0)。
|
||||
func regexGetElement(res string, regs []string, index int) []string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m, err := re.FindStringMatchStartingAt(res, 0)
|
||||
if err != nil || m == nil {
|
||||
return nil
|
||||
}
|
||||
if index+1 == len(regs) {
|
||||
info := make([]string, 0, len(m.Groups()))
|
||||
for _, g := range m.Groups() {
|
||||
if len(g.Captures) > 0 {
|
||||
info = append(info, g.Captures[0].String())
|
||||
} else {
|
||||
info = append(info, "")
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
var sb strings.Builder
|
||||
for m != nil {
|
||||
sb.WriteString(m.String())
|
||||
m, _ = re.FindNextMatch(m)
|
||||
}
|
||||
return regexGetElement(sb.String(), regs, index+1)
|
||||
}
|
||||
|
||||
// regexGetElements 对应 AnalyzeByRegex.getElements:多段正则串联,
|
||||
// 最终按每个匹配返回一组分组列表。
|
||||
func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m, err := re.FindStringMatchStartingAt(res, 0)
|
||||
if err != nil || m == nil {
|
||||
return nil
|
||||
}
|
||||
if index+1 == len(regs) {
|
||||
var books [][]string
|
||||
for m != nil {
|
||||
info := make([]string, 0, len(m.Groups()))
|
||||
for _, g := range m.Groups() {
|
||||
if len(g.Captures) > 0 {
|
||||
info = append(info, g.Captures[0].String())
|
||||
} else {
|
||||
info = append(info, "")
|
||||
}
|
||||
}
|
||||
books = append(books, info)
|
||||
m, _ = re.FindNextMatch(m)
|
||||
}
|
||||
return books
|
||||
}
|
||||
var sb strings.Builder
|
||||
for m != nil {
|
||||
sb.WriteString(m.String())
|
||||
m, _ = re.FindNextMatch(m)
|
||||
}
|
||||
return regexGetElements(sb.String(), regs, index+1)
|
||||
}
|
||||
|
||||
// regexReplaceAll 对应 Kotlin Regex.replace(result, replacement)
|
||||
// (Java $N 分组替换语义,regexp2 的 Replace 原生支持)。
|
||||
func regexReplaceAll(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(result, pattern, replacement)
|
||||
}
|
||||
out, err := re.Replace(result, replacement, 0, -1)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyUserReplace 应用一条用户替换净化规则(对应 legado ReplaceRule)。
|
||||
// isRegex=false 按字面替换;isRegex=true 用 Java 正则语义并带匹配超时
|
||||
// (防灾难性回溯挂死服务),编译失败回退字面替换。
|
||||
func ApplyUserReplace(content, pattern, replacement string, isRegex bool, timeoutMS int64) string {
|
||||
if pattern == "" {
|
||||
return content
|
||||
}
|
||||
if !isRegex {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
if timeoutMS > 0 {
|
||||
re.MatchTimeout = time.Duration(timeoutMS) * time.Millisecond
|
||||
} else {
|
||||
re.MatchTimeout = 3 * time.Second
|
||||
}
|
||||
out, err := re.Replace(content, replacement, 0, -1)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyReplaceRegexString 应用书源 replaceRegex 字符串("##pattern##replace[##x]" 格式),
|
||||
// 对应 ContentRule.replaceRegex 的处理。
|
||||
func ApplyReplaceRegexString(content, replaceRegex string) string {
|
||||
if replaceRegex == "" {
|
||||
return content
|
||||
}
|
||||
segs := strings.Split(replaceRegex, "##")
|
||||
if len(segs) < 2 {
|
||||
return content
|
||||
}
|
||||
pattern := segs[1]
|
||||
replacement := ""
|
||||
replaceFirst := false
|
||||
if len(segs) > 2 {
|
||||
replacement = segs[2]
|
||||
}
|
||||
if len(segs) > 3 {
|
||||
replaceFirst = true
|
||||
}
|
||||
if replaceFirst {
|
||||
return regexReplaceFirstOnFirstMatch(pattern, content, replacement)
|
||||
}
|
||||
return regexReplaceAll(pattern, content, replacement)
|
||||
}
|
||||
|
||||
// regexReplaceFirstOnFirstMatch 对应 replaceRegex 的 replaceFirst 分支:
|
||||
// 找到第一个匹配(无匹配返回 ""),在匹配文本上做首次替换。
|
||||
func regexReplaceFirstOnFirstMatch(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return replacement
|
||||
}
|
||||
m, err := re.FindStringMatch(result)
|
||||
if err != nil || m == nil {
|
||||
return ""
|
||||
}
|
||||
out, err := re.Replace(m.String(), replacement, 0, 1)
|
||||
if err != nil {
|
||||
return m.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── RuleAnalyzer ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestRuleAnalyzerSplitAndOr(t *testing.T) {
|
||||
ra := NewRuleAnalyzer("class.a&&tag.b&&id.c", false)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) != 3 || rules[0] != "class.a" || rules[1] != "tag.b" || rules[2] != "id.c" {
|
||||
t.Fatalf("unexpected split: %#v", rules)
|
||||
}
|
||||
if ra.ElementsType() != "&&" {
|
||||
t.Fatalf("elementsType = %q, want &&", ra.ElementsType())
|
||||
}
|
||||
// 与 Kotlin 一致:consumeToAny 取最左侧出现的分隔符,
|
||||
// 之后只按该分隔符切分(混合操作符时右侧保留原样,由上层递归处理)。
|
||||
ra2 := NewRuleAnalyzer("class.a&&tag.b||id.c", false)
|
||||
rules2 := ra2.SplitRule("&&", "||", "%%")
|
||||
if len(rules2) != 2 || rules2[0] != "class.a" || rules2[1] != "tag.b||id.c" {
|
||||
t.Fatalf("mixed split: %#v", rules2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAnalyzerBalancedGroup(t *testing.T) {
|
||||
// && 在选择器平衡组内不应被切分
|
||||
ra := NewRuleAnalyzer(`tag.div[class="x&&y"]@text&&class.z`, false)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("unexpected split: %#v", rules)
|
||||
}
|
||||
if rules[0] != `tag.div[class="x&&y"]@text` {
|
||||
t.Fatalf("rule[0] = %q", rules[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAnalyzerSplitByAt(t *testing.T) {
|
||||
ra := NewRuleAnalyzer("class.bookbox@h4@a@text", false)
|
||||
ra.Trim()
|
||||
rules := ra.SplitRule("@")
|
||||
if len(rules) != 4 {
|
||||
t.Fatalf("unexpected split: %#v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SourceRule 模式识别 ────────────────────────────────────────────────────
|
||||
|
||||
func TestSourceRuleModeDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
rule string
|
||||
contentIsJSON bool
|
||||
want Mode
|
||||
}{
|
||||
{"class.a@text", false, ModeDefault},
|
||||
{"$.data.name", false, ModeJson},
|
||||
{"$[0].name", false, ModeJson},
|
||||
{"//div[@class='a']/text()", false, ModeXPath},
|
||||
{"@XPath://div", false, ModeXPath},
|
||||
{"@Json:$.a", false, ModeJson},
|
||||
{"@CSS:.a@text", false, ModeDefault},
|
||||
{"title", true, ModeJson}, // 内容为 JSON 时默认走 Json 模式
|
||||
{"/html/body", false, ModeXPath},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := newSourceRule(c.rule, ModeDefault, c.contentIsJSON)
|
||||
if got.Mode != c.want {
|
||||
t.Errorf("rule %q mode = %v, want %v", c.rule, got.Mode, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRuleSplitPut(t *testing.T) {
|
||||
sr := newSourceRule(`class.a@text@put:{"key1":"class.b@text"}`, ModeDefault, false)
|
||||
if sr.Rule != "class.a@text" {
|
||||
t.Fatalf("rule after put split = %q", sr.Rule)
|
||||
}
|
||||
if sr.putMap["key1"] != "class.b@text" {
|
||||
t.Fatalf("putMap = %#v", sr.putMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeUpRuleGetVariable(t *testing.T) {
|
||||
sr := newSourceRule(`@get:{kw}`, ModeDefault, false)
|
||||
deps := &RuleDeps{Get: func(key string) string { return "搜索词" }}
|
||||
resolved, err := sr.MakeUpRule(nil, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Rule != "搜索词" {
|
||||
t.Fatalf("resolved = %q", resolved.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSourceRuleJSBlocks(t *testing.T) {
|
||||
rules := SplitSourceRule(`<js>1+1</js>class.a@text`, false, false, nil)
|
||||
if len(rules) != 2 || rules[0].Mode != ModeJs || rules[1].Mode != ModeDefault {
|
||||
t.Fatalf("rules = %#v", rules)
|
||||
}
|
||||
if rules[0].Rule != "1+1" {
|
||||
t.Fatalf("js body = %q", rules[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── jsoup 分析器 ──────────────────────────────────────────────────────────
|
||||
|
||||
const testHTML = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div class="box" id="main">
|
||||
<div class="item"><h3><a href="/book/1">斗破苍穹</a></h3><span class="author">天蚕土豆</span><p class="intro">测试<strong>简介</strong></p></div>
|
||||
<div class="item"><h3><a href="/book/2">凡人修仙传</a></h3><span class="author">忘语</span><p class="intro">凡人流</p></div>
|
||||
<div class="item"><h3><a href="/book/3">遮天</a></h3><span class="author">辰东</span><p class="intro">九龙拉棺</p></div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestJsoupGetElementsAndFields(t *testing.T) {
|
||||
a := newJsoupAnalyzer(testHTML)
|
||||
els := a.getElements("class.item")
|
||||
if len(els) != 3 {
|
||||
t.Fatalf("elements = %d, want 3", len(els))
|
||||
}
|
||||
name := a.getString("class.item.0@tag.h3@tag.a@text")
|
||||
if name != "斗破苍穹" {
|
||||
t.Fatalf("name = %q", name)
|
||||
}
|
||||
href := a.getString("class.item.0@tag.h3@tag.a@href")
|
||||
if href != "/book/1" {
|
||||
t.Fatalf("href = %q", href)
|
||||
}
|
||||
author := a.getString("class.item.1@class.author@text")
|
||||
if author != "忘语" {
|
||||
t.Fatalf("author = %q", author)
|
||||
}
|
||||
// all:拼接所有
|
||||
names := a.getStringList("class.item@tag.h3@tag.a@text")
|
||||
if len(names) != 3 || names[2] != "遮天" {
|
||||
t.Fatalf("names = %#v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsoupTextNodesAndOwnText(t *testing.T) {
|
||||
a := newJsoupAnalyzer(testHTML)
|
||||
// ownText:不含子元素文本
|
||||
intro := a.getString("class.item.0@tag.p@ownText")
|
||||
if intro != "测试" {
|
||||
t.Fatalf("ownText = %q", intro)
|
||||
}
|
||||
// text:含子元素文本(jsoup 不在内联元素间补空格)
|
||||
full := a.getString("class.item.0@tag.p@text")
|
||||
if full != "测试简介" {
|
||||
t.Fatalf("text = %q", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsoupIndexSyntax(t *testing.T) {
|
||||
a := newJsoupAnalyzer(testHTML)
|
||||
// 负索引:最后一个
|
||||
last := a.getString("class.item.-1@tag.a@text")
|
||||
if last != "遮天" {
|
||||
t.Fatalf("last = %q", last)
|
||||
}
|
||||
// 新式区间索引
|
||||
firstTwo := a.getStringList("class.item[0:1]@tag.a@text")
|
||||
if len(firstTwo) != 2 || firstTwo[0] != "斗破苍穹" || firstTwo[1] != "凡人修仙传" {
|
||||
t.Fatalf("range = %#v", firstTwo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsoupAndOrPercent(t *testing.T) {
|
||||
a := newJsoupAnalyzer(testHTML)
|
||||
// &&:合并两路结果
|
||||
merged := a.getStringList(`class.item.0@tag.a@text&&class.item.1@tag.a@text`)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("&& merged = %#v", merged)
|
||||
}
|
||||
// ||:第一个非空即停
|
||||
or := a.getStringList(`id.notexist@text||class.item.0@tag.a@text`)
|
||||
if len(or) != 1 || or[0] != "斗破苍穹" {
|
||||
t.Fatalf("|| result = %#v", or)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsoupCSSMode(t *testing.T) {
|
||||
a := newJsoupAnalyzer(testHTML)
|
||||
// @CSS: 末段为提取规则(与 jsoup 分析器一致的 @ 分离)
|
||||
name := a.getString("@CSS:#main .item:nth-child(1) a@text")
|
||||
if name != "斗破苍穹" {
|
||||
t.Fatalf("@css name = %q", name)
|
||||
}
|
||||
href := a.getString("@CSS:.item:nth-child(2) a@href")
|
||||
if href != "/book/2" {
|
||||
t.Fatalf("@css href = %q", href)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSONPath 分析器 ───────────────────────────────────────────────────────
|
||||
|
||||
const testJSON = `{"data":{"list":[{"title":"第一章","url":"/c/1"},{"title":"第二章","url":"/c/2"}],"name":"测试书","page":2}}`
|
||||
|
||||
func TestJSONPathGetString(t *testing.T) {
|
||||
a := newJSONAnalyzer(testJSON)
|
||||
if got := a.getString("$.data.name"); got != "测试书" {
|
||||
t.Fatalf("name = %q", got)
|
||||
}
|
||||
if got := a.getString("$.data.list[*].title"); got != "第一章\n第二章" {
|
||||
t.Fatalf("titles = %q", got)
|
||||
}
|
||||
// || 首个非空
|
||||
if got := a.getString("$.data.missing||$.data.name"); got != "测试书" {
|
||||
t.Fatalf("|| = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPathInnerRule(t *testing.T) {
|
||||
a := newJSONAnalyzer(testJSON)
|
||||
// {$.data.page} 内嵌规则替换
|
||||
got := a.getString("/api/list/{$.data.page}/next.json")
|
||||
if got != "/api/list/2/next.json" {
|
||||
t.Fatalf("inner = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPathListAndElementContext(t *testing.T) {
|
||||
a := newJSONAnalyzer(testJSON)
|
||||
list := a.getList("$.data.list[*]")
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("list = %#v", list)
|
||||
}
|
||||
// 以列表元素为根继续求值(对应 getString(rule, element))
|
||||
sub := newJSONAnalyzer(list[0])
|
||||
if got := sub.getString("$.title"); got != "第一章" {
|
||||
t.Fatalf("element title = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── XPath 分析器 ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestXPathAnalyzer(t *testing.T) {
|
||||
a := newXPathAnalyzer(testHTML)
|
||||
if got := a.getString(`//div[@class="item"][1]//a/text()`); got != "斗破苍穹" {
|
||||
t.Fatalf("xpath = %q", got)
|
||||
}
|
||||
hrefs := a.getStringList(`//div[@class="item"]//a/@href`)
|
||||
if len(hrefs) != 3 {
|
||||
t.Fatalf("hrefs = %#v", hrefs)
|
||||
}
|
||||
els := a.getElements(`//div[@class="item"]`)
|
||||
if len(els) != 3 {
|
||||
t.Fatalf("elements = %d", len(els))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 正则分析器 ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRegexAnalyzer(t *testing.T) {
|
||||
content := "第1章 开始 第2章 继续 第3章 结束"
|
||||
els := regexGetElements(content, []string{`第(\d+)章 ([^ ]+)`}, 0)
|
||||
if len(els) != 3 || els[0][1] != "1" || els[2][2] != "结束" {
|
||||
t.Fatalf("regex elements = %#v", els)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRegex(t *testing.T) {
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(testHTML, "http://x.com")
|
||||
// ## 目标##替换
|
||||
got, err := a.GetString(`class.item.0@tag.a@text##斗破##破斗`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "破斗苍穹" {
|
||||
t.Fatalf("replace = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AnalyzeUrl ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseAnalyzeUrlBasic(t *testing.T) {
|
||||
req, err := ParseAnalyzeUrl("https://example.com/search/{{key}}/1.html", "斗罗", 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 与 legado 一致:路径段不做百分号编码(执行时由 HTTP 客户端转义)
|
||||
if !strings.Contains(req.URL, "/search/斗罗/1.html") {
|
||||
t.Fatalf("url = %q", req.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlPageList(t *testing.T) {
|
||||
// <1,20,40>:page=1 → 1;page=2 → 20;page=5 → 40(取最后一档)
|
||||
for page, want := range map[int]string{1: "1", 2: "20", 5: "40"} {
|
||||
req, err := ParseAnalyzeUrl("https://e.com/list/<1,20,40>.html", "k", page, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(req.URL, want+".html") {
|
||||
t.Fatalf("page=%d url = %q", page, req.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlGBKQueryEncoding(t *testing.T) {
|
||||
req, err := ParseAnalyzeUrl("https://e.com/search.php?keyword={{key}},{\"charset\":\"gbk\"}", "斗罗", 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "斗罗" 的 GBK 编码 = B6 B7 C2 DE
|
||||
if !strings.Contains(req.URL, "%B6%B7%C2%DE") {
|
||||
t.Fatalf("gbk url = %q", req.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlPostForm(t *testing.T) {
|
||||
req, err := ParseAnalyzeUrl(`https://e.com/search,{"method":"POST","body":"searchkey={{key}}&submit=go"}`, "斗罗", 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
if !req.IsForm {
|
||||
t.Fatalf("body should be form, got %q", req.Body)
|
||||
}
|
||||
if !strings.Contains(req.Body, "searchkey=") {
|
||||
t.Fatalf("body = %q", req.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnalyzeUrlWebViewUnsupported(t *testing.T) {
|
||||
req, err := ParseAnalyzeUrl(`https://e.com/x,{"webView":true}`, "k", 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Unsupported == nil {
|
||||
t.Fatal("expected unsupported for webView")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── httptest 端到端:书源 JSON → 搜索解析全链路(见 reader 包 reader_test.go) ──
|
||||
@@ -0,0 +1,416 @@
|
||||
package rule
|
||||
|
||||
import "strings"
|
||||
|
||||
// RuleAnalyzer 移植自 legado RuleAnalyzer.kt,逐方法对齐。
|
||||
// 用于按 "@"/"&&"/"||"/"%%"/"@" 切分规则字符串,切分时跳过引号内与
|
||||
// 平衡组([...]、(...))内的分隔符,避免与选择器或正则内容冲突。
|
||||
|
||||
// RuleAnalyzer 是无状态的切分器实例(一次使用)。
|
||||
type RuleAnalyzer struct {
|
||||
queue string
|
||||
pos int
|
||||
start int
|
||||
startX int
|
||||
rule []string
|
||||
step int
|
||||
elementsType string
|
||||
code bool
|
||||
}
|
||||
|
||||
// NewRuleAnalyzer 对应 RuleAnalyzer(data, code);code=true 时平衡组按
|
||||
// 代码语义(处理转义、区分 [] 与 ())处理,用于 jsonPath 规则。
|
||||
func NewRuleAnalyzer(data string, code bool) *RuleAnalyzer {
|
||||
return &RuleAnalyzer{queue: data, code: code}
|
||||
}
|
||||
|
||||
// ElementsType 返回上次 splitRule 使用的组合符("&&"/"||"/"%%")。
|
||||
func (a *RuleAnalyzer) ElementsType() string { return a.elementsType }
|
||||
|
||||
// Rules 返回切分结果。
|
||||
func (a *RuleAnalyzer) Rules() []string { return a.rule }
|
||||
|
||||
// Trim 对应 trim():修剪当前规则之前的 "@" 或不可见字符。
|
||||
func (a *RuleAnalyzer) Trim() {
|
||||
if a.pos >= len(a.queue) {
|
||||
return
|
||||
}
|
||||
if a.queue[a.pos] == '@' || a.queue[a.pos] < '!' {
|
||||
a.pos++
|
||||
for a.pos < len(a.queue) && (a.queue[a.pos] == '@' || a.queue[a.pos] < '!') {
|
||||
a.pos++
|
||||
}
|
||||
a.start = a.pos
|
||||
a.startX = a.pos
|
||||
}
|
||||
}
|
||||
|
||||
// ReSetPos 对应 reSetPos()。
|
||||
func (a *RuleAnalyzer) ReSetPos() {
|
||||
a.pos = 0
|
||||
a.startX = 0
|
||||
}
|
||||
|
||||
// consumeTo 对应 consumeTo(seq)。
|
||||
func (a *RuleAnalyzer) consumeTo(seq string) bool {
|
||||
a.start = a.pos
|
||||
offset := strings.Index(a.queue[a.pos:], seq)
|
||||
if offset == -1 {
|
||||
return false
|
||||
}
|
||||
a.pos += offset
|
||||
return true
|
||||
}
|
||||
|
||||
// consumeToAny 对应 consumeToAny(seq)。
|
||||
func (a *RuleAnalyzer) consumeToAny(seqs ...string) bool {
|
||||
pos := a.pos
|
||||
for pos != len(a.queue) {
|
||||
for _, s := range seqs {
|
||||
if strings.HasPrefix(a.queue[pos:], s) {
|
||||
a.step = len(s)
|
||||
a.pos = pos
|
||||
return true
|
||||
}
|
||||
}
|
||||
pos++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findToAny 对应 findToAny(seq: Char)。
|
||||
func (a *RuleAnalyzer) findToAny(chars ...byte) int {
|
||||
pos := a.pos
|
||||
for pos != len(a.queue) {
|
||||
for _, c := range chars {
|
||||
if a.queue[pos] == c {
|
||||
return pos
|
||||
}
|
||||
}
|
||||
pos++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// chompCodeBalanced 对应 chompCodeBalanced(open, close):拉出一个平衡组,
|
||||
// 存在转义文本,'[' 与参数对 (open/close) 分别计数。
|
||||
func (a *RuleAnalyzer) chompCodeBalanced(open, close byte) bool {
|
||||
pos := a.pos
|
||||
depth := 0
|
||||
otherDepth := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for {
|
||||
if pos == len(a.queue) {
|
||||
break
|
||||
}
|
||||
c := a.queue[pos]
|
||||
pos++
|
||||
if c != '\\' {
|
||||
if c == '\'' && !inDouble {
|
||||
inSingle = !inSingle
|
||||
} else if c == '"' && !inSingle {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
if inSingle || inDouble {
|
||||
continue
|
||||
}
|
||||
if c == '[' {
|
||||
depth++
|
||||
} else if c == ']' {
|
||||
depth--
|
||||
} else if depth == 0 {
|
||||
if c == open {
|
||||
otherDepth++
|
||||
} else if c == close {
|
||||
otherDepth--
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pos++
|
||||
}
|
||||
if !(depth > 0 || otherDepth > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if depth > 0 || otherDepth > 0 {
|
||||
return false
|
||||
}
|
||||
a.pos = pos
|
||||
return true
|
||||
}
|
||||
|
||||
// chompRuleBalanced 对应 chompRuleBalanced(open, close):引号外才处理转义。
|
||||
func (a *RuleAnalyzer) chompRuleBalanced(open, close byte) bool {
|
||||
pos := a.pos
|
||||
depth := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for {
|
||||
if pos == len(a.queue) {
|
||||
break
|
||||
}
|
||||
c := a.queue[pos]
|
||||
pos++
|
||||
if c == '\'' && !inDouble {
|
||||
inSingle = !inSingle
|
||||
} else if c == '"' && !inSingle {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
if inSingle || inDouble {
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if c == open {
|
||||
depth++
|
||||
} else if c == close {
|
||||
depth--
|
||||
}
|
||||
if !(depth > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
return false
|
||||
}
|
||||
a.pos = pos
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *RuleAnalyzer) chompBalanced(open, close byte) bool {
|
||||
if a.code {
|
||||
return a.chompCodeBalanced(open, close)
|
||||
}
|
||||
return a.chompRuleBalanced(open, close)
|
||||
}
|
||||
|
||||
// SplitRule 对应 splitRule(vararg split):把 queue 切分成规则列表。
|
||||
// 首段按 splits 中最先出现的分隔符确定组合类型,其余按该类型循环切分。
|
||||
func (a *RuleAnalyzer) SplitRule(splits ...string) []string {
|
||||
if len(splits) == 1 {
|
||||
a.elementsType = splits[0]
|
||||
if !a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.startX:])
|
||||
return a.rule
|
||||
}
|
||||
a.step = len(a.elementsType)
|
||||
return a.splitRuleNext()
|
||||
}
|
||||
if !a.consumeToAny(splits...) {
|
||||
a.rule = append(a.rule, a.queue[a.startX:])
|
||||
return a.rule
|
||||
}
|
||||
|
||||
end := a.pos
|
||||
a.pos = a.start
|
||||
for {
|
||||
st := a.findToAny('[', '(')
|
||||
if st == -1 {
|
||||
a.rule = []string{a.queue[a.startX:end]}
|
||||
a.elementsType = a.queue[end : end+a.step]
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
if st > end {
|
||||
a.rule = []string{a.queue[a.startX:end]}
|
||||
a.elementsType = a.queue[end : end+a.step]
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) && a.pos < st {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
if a.pos > st {
|
||||
a.startX = a.start
|
||||
return a.splitRuleNext()
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
a.pos = st
|
||||
next := byte(')')
|
||||
if a.queue[a.pos] == '[' {
|
||||
next = ']'
|
||||
}
|
||||
if !a.chompBalanced(a.queue[a.pos], next) {
|
||||
return []string{a.queue}
|
||||
}
|
||||
if end <= a.pos {
|
||||
break
|
||||
}
|
||||
}
|
||||
a.start = a.pos
|
||||
return a.splitRuleFirst(splits...)
|
||||
}
|
||||
|
||||
// splitRuleFirst 对应首段匹配的 tailrec 递归(elementsType 尚未确定)。
|
||||
func (a *RuleAnalyzer) splitRuleFirst(splits ...string) []string {
|
||||
if len(splits) == 1 {
|
||||
a.elementsType = splits[0]
|
||||
if !a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.startX:])
|
||||
return a.rule
|
||||
}
|
||||
a.step = len(a.elementsType)
|
||||
return a.splitRuleNext()
|
||||
}
|
||||
if !a.consumeToAny(splits...) {
|
||||
a.rule = append(a.rule, a.queue[a.startX:])
|
||||
return a.rule
|
||||
}
|
||||
end := a.pos
|
||||
a.pos = a.start
|
||||
for {
|
||||
st := a.findToAny('[', '(')
|
||||
if st == -1 {
|
||||
a.rule = []string{a.queue[a.startX:end]}
|
||||
a.elementsType = a.queue[end : end+a.step]
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
if st > end {
|
||||
a.rule = []string{a.queue[a.startX:end]}
|
||||
a.elementsType = a.queue[end : end+a.step]
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) && a.pos < st {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
if a.pos > st {
|
||||
a.startX = a.start
|
||||
return a.splitRuleNext()
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
a.pos = st
|
||||
next := byte(')')
|
||||
if a.queue[a.pos] == '[' {
|
||||
next = ']'
|
||||
}
|
||||
if !a.chompBalanced(a.queue[a.pos], next) {
|
||||
return []string{a.queue}
|
||||
}
|
||||
if end <= a.pos {
|
||||
break
|
||||
}
|
||||
}
|
||||
a.start = a.pos
|
||||
return a.splitRuleFirst(splits...)
|
||||
}
|
||||
|
||||
// splitRuleNext 对应二段匹配 splitRule()(elementsType 已确定)。
|
||||
func (a *RuleAnalyzer) splitRuleNext() []string {
|
||||
for {
|
||||
end := a.pos
|
||||
a.pos = a.start
|
||||
for {
|
||||
st := a.findToAny('[', '(')
|
||||
if st == -1 {
|
||||
a.rule = append(a.rule, a.queue[a.startX:end])
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
if st > end {
|
||||
a.rule = append(a.rule, a.queue[a.startX:end])
|
||||
a.pos = end + a.step
|
||||
for a.consumeTo(a.elementsType) && a.pos < st {
|
||||
a.rule = append(a.rule, a.queue[a.start:a.pos])
|
||||
a.pos += a.step
|
||||
}
|
||||
if a.pos > st {
|
||||
a.startX = a.start
|
||||
return a.splitRuleNext()
|
||||
}
|
||||
a.rule = append(a.rule, a.queue[a.pos:])
|
||||
return a.rule
|
||||
}
|
||||
a.pos = st
|
||||
next := byte(')')
|
||||
if a.queue[a.pos] == '[' {
|
||||
next = ']'
|
||||
}
|
||||
if !a.chompBalanced(a.queue[a.pos], next) {
|
||||
return []string{a.queue}
|
||||
}
|
||||
if end <= a.pos {
|
||||
break
|
||||
}
|
||||
}
|
||||
a.start = a.pos
|
||||
if !a.consumeTo(a.elementsType) {
|
||||
a.rule = append(a.rule, a.queue[a.startX:])
|
||||
return a.rule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InnerRule 对应 innerRule(inner, startStep, endStep, fr) 第一变体:
|
||||
// 替换所有内嵌规则(如 {$.xxx}),fr 返回空表示该处不是有效内嵌规则。
|
||||
// 返回替换后的完整字符串;无一替换成功时返回 ""。
|
||||
func (a *RuleAnalyzer) InnerRule(inner string, startStep, endStep int, fr func(string) string) string {
|
||||
var sb []byte
|
||||
for {
|
||||
if !a.consumeTo(inner) {
|
||||
break
|
||||
}
|
||||
posPre := a.pos
|
||||
if a.chompCodeBalanced('{', '}') {
|
||||
frv := fr(a.queue[posPre+startStep : a.pos-endStep])
|
||||
if frv != "" {
|
||||
sb = append(sb, a.queue[a.startX:posPre]...)
|
||||
sb = append(sb, frv...)
|
||||
a.startX = a.pos
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.pos += len(inner)
|
||||
}
|
||||
if a.startX == 0 {
|
||||
return ""
|
||||
}
|
||||
sb = append(sb, a.queue[a.startX:]...)
|
||||
return string(sb)
|
||||
}
|
||||
|
||||
// InnerRule2 对应 innerRule(startStr, endStr, fr) 第二变体:无平衡组检查。
|
||||
// 无一替换成功时返回原串。
|
||||
func (a *RuleAnalyzer) InnerRule2(startStr, endStr string, fr func(string) string) string {
|
||||
var sb []byte
|
||||
for {
|
||||
if !a.consumeTo(startStr) {
|
||||
break
|
||||
}
|
||||
a.pos += len(startStr)
|
||||
posPre := a.pos
|
||||
if a.consumeTo(endStr) {
|
||||
frv := fr(a.queue[posPre:a.pos])
|
||||
sb = append(sb, a.queue[a.startX:posPre-len(startStr)]...)
|
||||
sb = append(sb, frv...)
|
||||
a.pos += len(endStr)
|
||||
a.startX = a.pos
|
||||
}
|
||||
}
|
||||
if a.startX == 0 {
|
||||
return a.queue
|
||||
}
|
||||
sb = append(sb, a.queue[a.startX:]...)
|
||||
return string(sb)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件用「聚合类登录书源」的典型结构验证服务端 JS 运行时:
|
||||
//
|
||||
// jsLib(公共函数库)+ loginUrl(登录逻辑)+ loginUi(表单)
|
||||
//
|
||||
// 与真实书源(如光遇聚合,jsLib 约 15 万字符)同构,但精简为可维护的固件。
|
||||
// 固件放在 testdata/,因此这些是常驻回归测试而非一次性 spike。
|
||||
|
||||
func loadTestdata(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("读取固件 %s 失败: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// sampleSourceProps 构造注入了 jsLib/loginUrl/loginUi 的书源属性。
|
||||
func sampleSourceProps(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"bookSourceUrl": "https://v1.example-aggregate.com",
|
||||
"bookSourceName": "样例聚合源",
|
||||
"jsLib": loadTestdata(t, "sample_jsLib.js"),
|
||||
"loginUrl": loadTestdata(t, "sample_loginUrl.js"),
|
||||
"loginUi": loadTestdata(t, "sample_loginUi.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// newSampleRunner 用聚合源固件构建运行时,网络由测试桩接管。
|
||||
func newSampleRunner(t *testing.T, state SourceState) *JSRunner {
|
||||
t.Helper()
|
||||
props := sampleSourceProps(t)
|
||||
r := NewJSRunner(JSConfig{
|
||||
SourceProps: props,
|
||||
JSLib: props["jsLib"].(string),
|
||||
State: state,
|
||||
BaseURL: "https://v1.example-aggregate.com",
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if strings.Contains(req.URL, "/login_api") {
|
||||
return `{"code":0,"key":"TOKEN_abcdefghijklmn"}`, req.URL, 200, nil
|
||||
}
|
||||
if strings.Contains(req.URL, "/user_api") {
|
||||
return `{"id":1,"email":"user@example.com","nickname":"tester"}`, req.URL, 200, nil
|
||||
}
|
||||
return `{}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("jsLib 执行失败: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestJSLibProvidesHelpers jsLib 里的函数与 lexical 绑定(let hosts)应跨执行可见。
|
||||
func TestJSLibProvidesHelpers(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`typeof getVariable + '|' + typeof request + '|' + typeof hosts`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalAction 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "function|function|object" {
|
||||
t.Fatalf("jsLib 符号不可见: %q", got)
|
||||
}
|
||||
// BaseUrl() 依赖 lexical hosts 与 source.getVariable
|
||||
v, err = r.EvalAction(`BaseUrl()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BaseUrl 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v1.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl() = %q", got)
|
||||
}
|
||||
// login 由 loginUrl 提供,未拼 loginUrl 时不应存在
|
||||
v, _ = r.EvalAction(`typeof login`, nil)
|
||||
if anyToString(v) != "undefined" {
|
||||
t.Fatalf("login 应仅由 loginUrl 定义,当前 %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginFlowWritesCookies login() 应请求登录接口、写入 Cookie,且 getToken 能读回。
|
||||
func TestLoginFlowWritesCookies(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
_, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "user@example.com", "密码": "pw123456"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("login(true) 执行失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getToken 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("getToken() = %q,期望登录后能读到 token", got)
|
||||
}
|
||||
// setAllCookies 遍历 hosts;两条线路同属 example-aggregate.com,
|
||||
// 按 eTLD+1 归并成一条 Cookie,两个线路地址都应能读到。
|
||||
if len(state.Snapshot()) != 1 {
|
||||
t.Fatalf("同站线路应归并为一条 Cookie,实际 %v", state.Snapshot())
|
||||
}
|
||||
for _, host := range []string{"https://v1.example-aggregate.com", "https://v2.example-aggregate.com"} {
|
||||
if !strings.Contains(state.GetCookie(host), "qttoken=TOKEN_abcdefghijklmn") {
|
||||
t.Fatalf("%s 未读到 token: %q", host, state.GetCookie(host))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "登录成功") {
|
||||
t.Fatalf("未收到登录成功提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginMissingCredentials 缺少账号密码时应给出提示且不产生 Cookie。
|
||||
func TestLoginMissingCredentials(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
if _, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{"result": map[string]string{}}); err != nil {
|
||||
t.Fatalf("执行失败: %v", err)
|
||||
}
|
||||
if len(state.Snapshot()) != 0 {
|
||||
t.Fatalf("未填表单不应写入 Cookie: %v", state.Snapshot())
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "请先输入账号密码") {
|
||||
t.Fatalf("缺少提示语: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartBrowserAwaitWithoutHost startBrowserAwait 在未注入宿主浏览器时应
|
||||
// 明确报错,并记录待打开地址,避免书源逻辑把空 body 当成校验成功。
|
||||
func TestStartBrowserAwaitWithoutHost(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
// 先造出登录态,让 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(), "浏览器") {
|
||||
t.Fatalf("应明确报不支持,实际: %v", err)
|
||||
}
|
||||
browsers := state.Browsers()
|
||||
if len(browsers) != 1 || !strings.HasSuffix(browsers[0].URL, "/user") {
|
||||
t.Fatalf("未记录待打开地址: %+v", browsers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginInfoMapInit 未保存登录信息时,getLoginInfoMap 用 loginUi 的
|
||||
// 非按钮字段初始化(对应 legado)。
|
||||
func TestSourceLoginInfoMapInit(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`JSON.stringify(source.getLoginInfoMap())`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getLoginInfoMap 失败: %v", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(anyToString(v)), &m); err != nil {
|
||||
t.Fatalf("返回不是 JSON 对象: %q", anyToString(v))
|
||||
}
|
||||
if _, ok := m["邮箱"]; !ok {
|
||||
t.Fatalf("loginUi 的「邮箱」未出现在登录信息: %v", m)
|
||||
}
|
||||
if _, ok := m["密码"]; !ok {
|
||||
t.Fatalf("loginUi 的「密码」未出现在登录信息: %v", m)
|
||||
}
|
||||
for k := range m {
|
||||
if strings.Contains(k, "登录") || strings.Contains(k, "注册") || strings.Contains(k, "后台") {
|
||||
t.Fatalf("button 字段不应出现在登录信息: %v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceVariablesRoundTrip 变量经 source.setVariable 写入后可由 getVariable 读出。
|
||||
func TestSourceVariablesRoundTrip(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
|
||||
if _, err := r.EvalAction(`setVariable('线路','https://v2.example-aggregate.com',false)`, nil); err != nil {
|
||||
t.Fatalf("setVariable 失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getVariable('线路')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("getVariable('线路') = %q", got)
|
||||
}
|
||||
// 变量变更应反映到状态存储(持久化的前提)
|
||||
if !strings.Contains(state.GetVariable(), "v2.example-aggregate.com") {
|
||||
t.Fatalf("变量未写回状态存储: %q", state.GetVariable())
|
||||
}
|
||||
// BaseUrl 也应跟随变量变化
|
||||
v, _ = r.EvalAction(`BaseUrl()`, nil)
|
||||
if anyToString(v) != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl 未跟随变量: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieObjectIsolation Cookie 按站点隔离,同站子域共享,removeCookie 可清除。
|
||||
func TestCookieObjectIsolation(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := NewJSRunner(JSConfig{State: state})
|
||||
|
||||
if _, err := r.EvalAction(`cookie.setCookie('https://a.example.com/x','t=1; u=2')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := r.EvalAction(`cookie.getCookie('https://a.example.com/y','t')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "1" {
|
||||
t.Fatalf("getCookie key = %q", anyToString(v))
|
||||
}
|
||||
// 同站子域共享 Cookie(对应 legado getSubDomain 取 eTLD+1)
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://www.example.com/')`, nil)
|
||||
if anyToString(v) == "" {
|
||||
t.Fatal("同站子域应共享 Cookie")
|
||||
}
|
||||
// 不同站点互不污染
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://other-site.net/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("跨站点读到了 Cookie: %q", anyToString(v))
|
||||
}
|
||||
// removeCookie 后读不到
|
||||
if _, err := r.EvalAction(`cookie.removeCookie('https://a.example.com')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://a.example.com/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("removeCookie 后仍读到: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatePersistsAcrossRunners 状态存储是跨运行时共享的:
|
||||
// 模拟服务端重启(新建 runner)后登录态仍在。
|
||||
func TestSourceStatePersistsAcrossRunners(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
r1 := newSampleRunner(t, state)
|
||||
if _, err := r1.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 新运行时复用同一 state(对应从 DB 重新载入)
|
||||
r2 := newSampleRunner(t, state)
|
||||
v, err := r2.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("新运行时未读到登录态: %q", anyToString(v))
|
||||
}
|
||||
// 已登录时再次 login 应提示已登录(走 getToken 短路分支)
|
||||
if _, err := r2.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "已登录") {
|
||||
t.Fatalf("已登录时应有提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReplacesBody loginCheckJs 返回新响应体时应替换原 body
|
||||
// (书源借此检测会话失效并重取页面)。
|
||||
func TestLoginCheckJsReplacesBody(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
// 返回响应对象(对应 legado 要求 loginCheckJs 返回 StrResponse)
|
||||
js := `if (result.body().indexOf('未登录') >= 0) { java.toast('会话失效,重新登录'); }
|
||||
var ok = JSON.stringify({code: 0, body: result.body()});
|
||||
result.body()`
|
||||
body, changed, err := r.EvalLoginCheck(js, "未登录", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatalf("EvalLoginCheck 失败: %v", err)
|
||||
}
|
||||
if !changed || body != "未登录" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReturnsString loginCheckJs 直接返回字符串时也应生效。
|
||||
func TestLoginCheckJsReturnsString(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(`'已修复内容'`, "原始", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "已修复内容" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsResponseAccessors 验证 result 暴露 code()/url()/header()。
|
||||
func TestLoginCheckJsResponseAccessors(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(
|
||||
`result.code() + '|' + result.url()`, "x", 403, "https://x.com/y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "403|https://x.com/y" {
|
||||
t.Fatalf("body=%q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeRule.kt 中的 SourceRule 内部类与 splitSourceRule。
|
||||
|
||||
// Mode 规则模式(对应 AnalyzeRule.Mode)。
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeXPath Mode = iota
|
||||
ModeJson
|
||||
ModeDefault
|
||||
ModeJs
|
||||
ModeRegex
|
||||
ModeWebJs
|
||||
)
|
||||
|
||||
var (
|
||||
jsPatternRe = regexp.MustCompile(`(?i)<js>([\w\W]*?)</js>|@js:([\w\W]*)`)
|
||||
webJsPatternRe = regexp.MustCompile(`(?i)@webjs:([\w\W]{5,})`)
|
||||
putPatternRe = regexp.MustCompile(`(?i)@put:(\{[^}]+?\})`)
|
||||
evalPatternRe = regexp.MustCompile(`(?i)@get:\{[^}]+?\}|\{\{[\w\W]*?\}\}`)
|
||||
regexGroupRe = regexp.MustCompile(`\$\d{1,2}`)
|
||||
)
|
||||
|
||||
// makeUpRule 参数类型(对应 SourceRule 的 ruleType 常量)。
|
||||
const (
|
||||
paramGet = -2 // @get:{name}
|
||||
paramJs = -1 // {{js}}
|
||||
paramText = 0 // 字面文本
|
||||
paramGroupN = 1 // >0 为 $N 正则分组引用,typ 即分组序号
|
||||
)
|
||||
|
||||
type ruleParam struct {
|
||||
typ int
|
||||
val string
|
||||
}
|
||||
|
||||
// SourceRule 是拆分后的单条规则(对应 AnalyzeRule.SourceRule)。
|
||||
type SourceRule struct {
|
||||
Rule string
|
||||
Mode Mode
|
||||
|
||||
putMap map[string]string
|
||||
ruleParams []ruleParam
|
||||
}
|
||||
|
||||
// SplitSourceRule 对应 AnalyzeRule.splitSourceRule(ruleStr, allInOne)。
|
||||
// contentIsJSON 对应 Kotlin 成员 isJSON(当前内容是否为 JSON)。
|
||||
// isRegex 对应 AnalyzeRule.isRegex 持久标记,可为 nil。
|
||||
func SplitSourceRule(ruleStr string, allInOne, contentIsJSON bool, isRegex *bool) []*SourceRule {
|
||||
if ruleStr == "" {
|
||||
return nil
|
||||
}
|
||||
mode := ModeDefault
|
||||
start := 0
|
||||
if allInOne && strings.HasPrefix(ruleStr, ":") {
|
||||
mode = ModeRegex
|
||||
if isRegex != nil {
|
||||
*isRegex = true
|
||||
}
|
||||
start = 1
|
||||
} else if isRegex != nil && *isRegex {
|
||||
mode = ModeRegex
|
||||
}
|
||||
|
||||
type rulePart struct {
|
||||
start int
|
||||
end int
|
||||
rule string
|
||||
mode Mode
|
||||
}
|
||||
var parts []rulePart
|
||||
for _, g := range jsPatternRe.FindAllStringSubmatchIndex(ruleStr, -1) {
|
||||
jsBody := ""
|
||||
if g[2] >= 0 { // group(1): <js>...</js>
|
||||
jsBody = ruleStr[g[2]:g[3]]
|
||||
} else if g[4] >= 0 { // group(2): @js:...
|
||||
jsBody = ruleStr[g[4]:g[5]]
|
||||
}
|
||||
parts = append(parts, rulePart{g[0], g[1], jsBody, ModeJs})
|
||||
}
|
||||
for _, g := range webJsPatternRe.FindAllStringSubmatchIndex(ruleStr, -1) {
|
||||
parts = append(parts, rulePart{g[0], g[1], ruleStr[g[2]:g[3]], ModeWebJs})
|
||||
}
|
||||
sort.Slice(parts, func(i, j int) bool { return parts[i].start < parts[j].start })
|
||||
|
||||
var out []*SourceRule
|
||||
for _, p := range parts {
|
||||
if p.start < start {
|
||||
continue
|
||||
}
|
||||
if p.start > start {
|
||||
if tmp := strings.TrimSpace(ruleStr[start:p.start]); tmp != "" {
|
||||
out = append(out, newSourceRule(tmp, mode, contentIsJSON))
|
||||
}
|
||||
}
|
||||
out = append(out, newSourceRule(p.rule, p.mode, contentIsJSON))
|
||||
start = p.end
|
||||
}
|
||||
if len(ruleStr) > start {
|
||||
if tmp := strings.TrimSpace(ruleStr[start:]); tmp != "" {
|
||||
out = append(out, newSourceRule(tmp, mode, contentIsJSON))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newSourceRule 对应 SourceRule.init:模式识别 + 分离 put + 拆分 @get/{{}}/$N。
|
||||
func newSourceRule(ruleStr string, mode Mode, contentIsJSON bool) *SourceRule {
|
||||
sr := &SourceRule{Mode: mode, putMap: map[string]string{}}
|
||||
rule := ruleStr
|
||||
switch {
|
||||
case mode == ModeJs || mode == ModeRegex:
|
||||
// 保持原样
|
||||
case hasPrefixFold(ruleStr, "@CSS:"):
|
||||
sr.Mode = ModeDefault
|
||||
case strings.HasPrefix(ruleStr, "@@"):
|
||||
sr.Mode = ModeDefault
|
||||
rule = ruleStr[2:]
|
||||
case hasPrefixFold(ruleStr, "@XPath:"):
|
||||
sr.Mode = ModeXPath
|
||||
rule = ruleStr[7:]
|
||||
case hasPrefixFold(ruleStr, "@Json:"):
|
||||
sr.Mode = ModeJson
|
||||
rule = ruleStr[6:]
|
||||
case contentIsJSON || strings.HasPrefix(ruleStr, "$.") || strings.HasPrefix(ruleStr, "$["):
|
||||
sr.Mode = ModeJson
|
||||
case strings.HasPrefix(ruleStr, "/"):
|
||||
sr.Mode = ModeXPath
|
||||
}
|
||||
// 分离 @put:{...}
|
||||
rule = splitPutRule(rule, sr.putMap)
|
||||
sr.Rule = rule
|
||||
sr.splitEvalParams()
|
||||
return sr
|
||||
}
|
||||
|
||||
func hasPrefixFold(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
// splitPutRule 对应 splitPutRule:提取并移除 @put:{...} 段。
|
||||
func splitPutRule(ruleStr string, putMap map[string]string) string {
|
||||
out := ruleStr
|
||||
for _, m := range putPatternRe.FindAllStringSubmatch(ruleStr, -1) {
|
||||
out = strings.Replace(out, m[0], "", 1)
|
||||
parsed := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(m[1]), &parsed); err == nil {
|
||||
for k, v := range parsed {
|
||||
putMap[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 宽松解析(对应 GSON lenient):key:val 键值对
|
||||
pairRe := regexp.MustCompile(`["']?(\w+)["']?\s*:\s*(["']?)([^,{}]*?)\2`)
|
||||
for _, pm := range pairRe.FindAllStringSubmatch(m[1], -1) {
|
||||
putMap[pm[1]] = pm[3]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitEvalParams 对应 init 中 @get/{{ }} 的拆分循环。
|
||||
func (sr *SourceRule) splitEvalParams() {
|
||||
rule := sr.Rule
|
||||
start := 0
|
||||
locs := evalPatternRe.FindAllStringIndex(rule, -1)
|
||||
if len(locs) > 0 {
|
||||
firstStart := locs[0][0]
|
||||
prefix := rule[:firstStart]
|
||||
if sr.Mode != ModeJs && sr.Mode != ModeRegex &&
|
||||
(firstStart == 0 || !strings.Contains(prefix, "##")) {
|
||||
sr.Mode = ModeRegex
|
||||
}
|
||||
for _, loc := range locs {
|
||||
if loc[0] > start {
|
||||
sr.splitRegex(rule[start:loc[0]])
|
||||
}
|
||||
tmp := rule[loc[0]:loc[1]]
|
||||
switch {
|
||||
case hasPrefixFold(tmp, "@get:"):
|
||||
sr.ruleParams = append(sr.ruleParams, ruleParam{paramGet, tmp[6 : len(tmp)-1]})
|
||||
case strings.HasPrefix(tmp, "{{"):
|
||||
sr.ruleParams = append(sr.ruleParams, ruleParam{paramJs, tmp[2 : len(tmp)-2]})
|
||||
default:
|
||||
sr.splitRegex(tmp)
|
||||
}
|
||||
start = loc[1]
|
||||
}
|
||||
}
|
||||
if len(rule) > start {
|
||||
sr.splitRegex(rule[start:])
|
||||
}
|
||||
}
|
||||
|
||||
// splitRegex 对应 SourceRule.splitRegex:拆分 $N 分组引用。
|
||||
// $N 匹配只作用于 "##" 前的第一段,"##..." 尾部作为字面参数保留,
|
||||
// 由 MakeUpRule 重新按 "##" 切分。
|
||||
func (sr *SourceRule) splitRegex(ruleStr string) {
|
||||
start := 0
|
||||
first := strings.Split(ruleStr, "##")[0]
|
||||
locs := regexGroupRe.FindAllStringIndex(first, -1)
|
||||
if len(locs) > 0 {
|
||||
if sr.Mode != ModeJs && sr.Mode != ModeRegex {
|
||||
sr.Mode = ModeRegex
|
||||
}
|
||||
for _, loc := range locs {
|
||||
if loc[0] > start {
|
||||
sr.ruleParams = append(sr.ruleParams, ruleParam{paramText, ruleStr[start:loc[0]]})
|
||||
}
|
||||
n := 0
|
||||
fmt.Sscanf(ruleStr[loc[0]+1:loc[1]], "%d", &n)
|
||||
sr.ruleParams = append(sr.ruleParams, ruleParam{n, ruleStr[loc[0]:loc[1]]})
|
||||
start = loc[1]
|
||||
}
|
||||
}
|
||||
if len(ruleStr) > start {
|
||||
sr.ruleParams = append(sr.ruleParams, ruleParam{paramText, ruleStr[start:]})
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedSourceRule 对应 AnalyzeRule.ResolvedSourceRule。
|
||||
type ResolvedSourceRule struct {
|
||||
Rule string
|
||||
ReplaceRegex string
|
||||
Replacement string
|
||||
ReplaceFirst bool
|
||||
ParamSize int
|
||||
}
|
||||
|
||||
// RuleDeps 是 MakeUpRule 解析内嵌 {{...}} 时需要的执行环境。
|
||||
type RuleDeps struct {
|
||||
// JS 执行 {{js}}(P0 未接入 goja 时返回 ErrJsUnsupported)
|
||||
JS func(js string, result any) (any, error)
|
||||
// Rule 执行 {{@rule}} / {{$.rule}} 形式的规则引用
|
||||
Rule func(rule string) (string, error)
|
||||
// Get 对应 AnalyzeRule.get(key)
|
||||
Get func(key string) string
|
||||
}
|
||||
|
||||
// MakeUpRule 对应 SourceRule.makeUpRule(result):替换 @get/{{}}/$N,
|
||||
// 再按 "##" 切分出正则段。
|
||||
func (sr *SourceRule) MakeUpRule(result any, deps *RuleDeps) (ResolvedSourceRule, error) {
|
||||
resolved := sr.Rule
|
||||
if len(sr.ruleParams) > 0 {
|
||||
var infoVal []byte
|
||||
for i := len(sr.ruleParams) - 1; i >= 0; i-- {
|
||||
p := sr.ruleParams[i]
|
||||
switch {
|
||||
case p.typ >= paramGroupN:
|
||||
// 对应 Kotlin:(result as? List<String?>) 成功但越界时不插入任何内容
|
||||
switch lst := result.(type) {
|
||||
case []string:
|
||||
if len(lst) > p.typ {
|
||||
infoVal = prepend(infoVal, lst[p.typ])
|
||||
}
|
||||
case []any:
|
||||
if len(lst) > p.typ {
|
||||
if s, ok := lst[p.typ].(string); ok {
|
||||
infoVal = prepend(infoVal, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
infoVal = prepend(infoVal, p.val)
|
||||
}
|
||||
case p.typ == paramJs:
|
||||
if isRuleString(p.val) {
|
||||
s, err := deps.Rule(p.val)
|
||||
if err != nil {
|
||||
return ResolvedSourceRule{}, err
|
||||
}
|
||||
infoVal = prepend(infoVal, s)
|
||||
} else {
|
||||
v, err := deps.JS(p.val, result)
|
||||
if err != nil {
|
||||
return ResolvedSourceRule{}, err
|
||||
}
|
||||
infoVal = prepend(infoVal, anyToString(v))
|
||||
}
|
||||
case p.typ == paramGet:
|
||||
infoVal = prepend(infoVal, deps.Get(p.val))
|
||||
default:
|
||||
infoVal = prepend(infoVal, p.val)
|
||||
}
|
||||
}
|
||||
resolved = string(infoVal)
|
||||
}
|
||||
segs := strings.Split(resolved, "##")
|
||||
r := ResolvedSourceRule{
|
||||
Rule: strings.TrimSpace(segs[0]),
|
||||
ReplaceFirst: len(segs) > 3,
|
||||
ParamSize: len(sr.ruleParams),
|
||||
}
|
||||
if len(segs) > 1 {
|
||||
r.ReplaceRegex = segs[1]
|
||||
}
|
||||
if len(segs) > 2 {
|
||||
r.Replacement = segs[2]
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func prepend(dst []byte, s string) []byte {
|
||||
return append([]byte(s), dst...)
|
||||
}
|
||||
|
||||
func isRuleString(s string) bool {
|
||||
return strings.HasPrefix(s, "@") ||
|
||||
strings.HasPrefix(s, "$.") ||
|
||||
strings.HasPrefix(s, "$[") ||
|
||||
strings.HasPrefix(s, "//")
|
||||
}
|
||||
|
||||
// anyToString 对应 Kotlin 值字符串化(整值 Double 去小数,对应 %.0f)。
|
||||
func anyToString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%.0f", t)
|
||||
}
|
||||
return fmt.Sprintf("%v", t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// 本文件:书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)的抽象。
|
||||
// 对应 legado BaseSource 的 getVariable/putVariable/getLoginInfo/putLoginInfo/
|
||||
// getLoginHeader/putLoginHeader 与 CookieStore。实现由服务层落库提供;
|
||||
// 未注入时退回进程内 MemoryState(单测、冒烟 CLI 用)。
|
||||
|
||||
// SourceState 书源会话状态读写接口。
|
||||
type SourceState interface {
|
||||
// GetVariable / SetVariable 对应 source.getVariable / source.setVariable。
|
||||
GetVariable() string
|
||||
SetVariable(v string)
|
||||
|
||||
// GetLoginInfo / SetLoginInfo 对应 source.getLoginInfo / source.putLoginInfo。
|
||||
// 内容为登录表单的 JSON(键为字段名)。
|
||||
GetLoginInfo() string
|
||||
SetLoginInfo(v string)
|
||||
|
||||
// GetLoginHeader / SetLoginHeader 对应 source 的登录请求头(JSON),
|
||||
// 其中 Cookie 键会在请求时合并进 Cookie 头。
|
||||
GetLoginHeader() string
|
||||
SetLoginHeader(v string)
|
||||
|
||||
// Cookie 读写:rawURL 可为完整 URL 或裸域名,按有效顶级域+1 归并。
|
||||
GetCookie(rawURL string) string
|
||||
GetCookieKey(rawURL, key string) string
|
||||
SetCookie(rawURL, cookie string)
|
||||
RemoveCookie(rawURL string)
|
||||
|
||||
// Toast 收集宿主提示(java.toast / java.longToast),登录反馈靠它回传前端。
|
||||
Toast(msg string)
|
||||
// OpenBrowser 记录需要浏览器完成的地址(java.startBrowser)——
|
||||
// 服务端无法弹窗,前端据此提供「在新标签打开」。
|
||||
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 {
|
||||
s := strings.TrimSpace(rawURL)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(s, "://") {
|
||||
if strings.HasPrefix(s, "//") {
|
||||
s = "http:" + s
|
||||
} else {
|
||||
s = "http://" + s
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
}
|
||||
etld1, err := publicsuffix.EffectiveTLDPlusOne(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return etld1
|
||||
}
|
||||
|
||||
// ParseCookie 把 "a=1; b=2" 拆为键值对(值不 Trim 内部空白,仅去首尾)。
|
||||
func ParseCookie(cookie string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 || kv[0] == "" {
|
||||
continue
|
||||
}
|
||||
out[kv[0]] = kv[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeCookie 将新 cookie 覆盖式合并进旧 cookie(对应 CookieStore.replaceCookie)。
|
||||
func MergeCookie(old, newCookie string) string {
|
||||
merged := ParseCookie(old)
|
||||
for k, v := range ParseCookie(newCookie) {
|
||||
merged[k] = v
|
||||
}
|
||||
return formatCookie(merged)
|
||||
}
|
||||
|
||||
func formatCookie(kv map[string]string) string {
|
||||
parts := make([]string, 0, len(kv))
|
||||
for k, v := range kv {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// MemoryState 进程内会话状态(未注入持久化实现时的回退)。
|
||||
type MemoryState struct {
|
||||
mu sync.Mutex
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]map[string]string // domain → name → value
|
||||
toasts []string
|
||||
browsers []BrowserRequest
|
||||
uiRefresh bool
|
||||
}
|
||||
|
||||
// BrowserRequest 前端可代为打开的浏览器地址(java.startBrowser 收集)。
|
||||
type BrowserRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// NewMemoryState 创建进程内状态。
|
||||
func NewMemoryState() *MemoryState {
|
||||
return &MemoryState{cookies: map[string]map[string]string{}}
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetVariable() string { m.mu.Lock(); defer m.mu.Unlock(); return m.variable }
|
||||
func (m *MemoryState) SetVariable(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.variable = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginInfo() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginInfo }
|
||||
func (m *MemoryState) SetLoginInfo(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginInfo = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginHeader() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginHeader }
|
||||
func (m *MemoryState) SetLoginHeader(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginHeader = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookie(rawURL string) string {
|
||||
return m.GetCookieKey(rawURL, "")
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookieKey(rawURL, key string) string {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return jar[key]
|
||||
}
|
||||
return formatCookie(jar)
|
||||
}
|
||||
|
||||
func (m *MemoryState) SetCookie(rawURL, cookie string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
jar = map[string]string{}
|
||||
m.cookies[domain] = jar
|
||||
}
|
||||
for k, v := range ParseCookie(cookie) {
|
||||
jar[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryState) RemoveCookie(rawURL string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.cookies, domain)
|
||||
}
|
||||
|
||||
func (m *MemoryState) Toast(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.toasts = append(m.toasts, msg)
|
||||
}
|
||||
|
||||
func (m *MemoryState) OpenBrowser(url, title string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
out := m.toasts
|
||||
m.toasts = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Browsers 返回并清空已收集的浏览器地址。
|
||||
func (m *MemoryState) Browsers() []BrowserRequest {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := m.browsers
|
||||
m.browsers = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot 返回当前 Cookie(domain → cookie 串)副本。
|
||||
func (m *MemoryState) Snapshot() map[string]string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[string]string, len(m.cookies))
|
||||
for d, jar := range m.cookies {
|
||||
out[d] = formatCookie(jar)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ SourceState = (*MemoryState)(nil)
|
||||
@@ -0,0 +1,117 @@
|
||||
// 测试用 jsLib:模拟聚合类书源的公共函数库结构。
|
||||
//
|
||||
// 真实书源(如光遇聚合)的 jsLib 有十几万字符,但关键结构就是这些:
|
||||
// 用 source.getVariable 做设置读写、用 cookie 存取凭证、用 BaseUrl() 拼接口地址。
|
||||
// 该文件由 JSRunner 在创建时执行一次,其函数对后续所有规则 JS 可见。
|
||||
// 同时保留了源码里常见的 lexical 声明(let hosts),验证跨执行可见性。
|
||||
|
||||
// 当前书源版本号
|
||||
let localVersion = '26.9.29.1';
|
||||
|
||||
// 初始服务器列表
|
||||
let hosts = [
|
||||
'https://v1.example-aggregate.com',
|
||||
'https://v2.example-aggregate.com'
|
||||
];
|
||||
|
||||
// 源变量默认值
|
||||
const defaultConfig = {
|
||||
线路: hosts[0],
|
||||
发现页来源: '番茄'
|
||||
};
|
||||
|
||||
// 获取源变量
|
||||
function getVariable(k) {
|
||||
if (k == undefined) k = "";
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(source.getVariable());
|
||||
} catch (e) {}
|
||||
if (k == "") {
|
||||
return parsed;
|
||||
}
|
||||
let value = parsed[k];
|
||||
if (value == undefined) {
|
||||
value = defaultConfig[k];
|
||||
}
|
||||
return value != undefined ? value : "";
|
||||
}
|
||||
|
||||
// 设置源变量
|
||||
function setVariable(k, v, t) {
|
||||
if (t == undefined) t = true;
|
||||
const vs = getVariable();
|
||||
vs[k] = v;
|
||||
source.setVariable(JSON.stringify(vs, null, 4));
|
||||
if (k != '云端配置' && t) {
|
||||
java.toast('设置 ' + k + ' 为 ' + v);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取正在使用的线路
|
||||
function BaseUrl() {
|
||||
let h = getVariable("线路");
|
||||
if (!h || String(h) == "undefined") {
|
||||
h = hosts[0];
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// 获取登陆 token
|
||||
function getToken() {
|
||||
let hostsbk = getVariable('云端配置') && getVariable('云端配置')['hosts'] || hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
let cookieValue = String(cookie.getCookie(hostsbk[i]));
|
||||
let parts = cookieValue.split(";");
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
if (parts[j].indexOf("qttoken") != -1) {
|
||||
return parts[j].split("=")[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 设置 ck
|
||||
function setAllCookies(ck) {
|
||||
let hostsbk = hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
cookie.setCookie(hostsbk[i], ck);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 ck
|
||||
function removeAllCookies() {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
cookie.removeCookie(hosts[i]);
|
||||
}
|
||||
java.toast('已退出登陆');
|
||||
}
|
||||
|
||||
// 请求封装(自动带 token)
|
||||
function request(url, method, body) {
|
||||
if (method == undefined) method = 'GET';
|
||||
if (body == undefined) body = {};
|
||||
let urla = url;
|
||||
if (url.indexOf('http') != 0) {
|
||||
urla = BaseUrl() + url;
|
||||
}
|
||||
let qttoken = getToken();
|
||||
let options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'cookie': 'qttoken=' + qttoken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
return java.ajax(urla + ',' + JSON.stringify(options));
|
||||
}
|
||||
|
||||
function parseJsonSafely(str) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"name": "线路设置",
|
||||
"type": "button",
|
||||
"action": "switchLine()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "邮箱",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "密码",
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"name": "🔅登录账号",
|
||||
"type": "button",
|
||||
"action": "login(true)",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🔐注册账号",
|
||||
"type": "button",
|
||||
"action": "register()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": " 🔚 退出登录",
|
||||
"type": "button",
|
||||
"action": "logout()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🪪 查看信息",
|
||||
"type": "button",
|
||||
"action": "checkStatus()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🏝用户后台",
|
||||
"type": "button",
|
||||
"action": "user()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
// 测试用 loginUrl:模拟聚合类书源的登录逻辑。
|
||||
//
|
||||
// 约定与 legado 一致:
|
||||
// - loginUrl 本身是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
|
||||
// - 表单值通过作用域里的 result 对象读取;
|
||||
// - 成功与否靠抛异常区分(返回值被忽略)。
|
||||
|
||||
function login(flag) {
|
||||
let token = getToken();
|
||||
if (String(token).length > 10) {
|
||||
java.longToast('当前已登录,请退出登录后重新登录');
|
||||
return true;
|
||||
}
|
||||
let email = result.邮箱;
|
||||
let pwd = result.密码;
|
||||
if (!email || !pwd) {
|
||||
java.longToast('请先输入账号密码!');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let data = request('/login_api', 'POST', {
|
||||
register_email: email,
|
||||
password: pwd
|
||||
});
|
||||
let response = parseJsonSafely(data);
|
||||
if (response && response.code == 0) {
|
||||
setAllCookies('qttoken=' + response.key);
|
||||
java.longToast('✅️登录成功');
|
||||
return true;
|
||||
}
|
||||
java.longToast('❌登录失败:' + ((response && response.msg) || '未知错误'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
java.longToast('❌登录失败,服务器错误');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登陆
|
||||
function logout() {
|
||||
removeAllCookies();
|
||||
}
|
||||
|
||||
// 用户后台(需要浏览器打开)
|
||||
function user() {
|
||||
if (String(getToken()).length < 10) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
let res = parseJsonSafely(request('/user_api', 'POST'));
|
||||
if (!res || res.id == undefined) {
|
||||
java.toast('获取用户信息失败');
|
||||
return;
|
||||
}
|
||||
result.邮箱 = res.email;
|
||||
source.putLoginInfo(JSON.stringify(result));
|
||||
java.longToast('昵称:' + (res.nickname || '未设置'));
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeUrl.kt 的 URL 解析部分(不含网络执行与 JS 执行)。
|
||||
|
||||
// URLOption 对应 AnalyzeUrl.UrlOption。
|
||||
type URLOption struct {
|
||||
Method string `json:"method"`
|
||||
Charset string `json:"charset"`
|
||||
Headers map[string]any `json:"headers"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
Origin string `json:"origin"`
|
||||
Retry *int `json:"retry"`
|
||||
Type string `json:"type"`
|
||||
WebView json.RawMessage `json:"webView"`
|
||||
WebJs string `json:"webJs"`
|
||||
DnsIp string `json:"dnsIp"`
|
||||
Js string `json:"js"`
|
||||
BodyJs string `json:"bodyJs"`
|
||||
ServerID json.RawMessage `json:"serverID"`
|
||||
WebViewDelayTime json.RawMessage `json:"webViewDelayTime"`
|
||||
}
|
||||
|
||||
// Request 是解析后的可执行请求(供服务层执行)。
|
||||
type Request struct {
|
||||
URL string // 最终 URL(GET 时已含重编码后的 query)
|
||||
URLNoQuery string
|
||||
Method string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
IsForm bool // Body 为已编码的 form 数据
|
||||
IsJSON bool // 以 application/json 发送
|
||||
Charset string
|
||||
// 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;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
// key/page 对应搜索关键词与页码({{key}}/{{page}}/<1,2,3>)。
|
||||
func ParseAnalyzeUrl(mUrl, key string, page int, baseUrl string) (*Request, error) {
|
||||
return ParseAnalyzeUrlWithJS(mUrl, key, page, baseUrl, nil)
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrlWithJS 在 ParseAnalyzeUrl 基础上支持 JS:
|
||||
// URL 中的 <js>/@js: 块、{{js}} 内嵌、选项里的 js/bodyJs。
|
||||
// runner 为 nil 时遇到 JS 标记 Unsupported(P0 兼容路径)。
|
||||
func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *JSRunner) (*Request, error) {
|
||||
req := &Request{Method: "GET", Headers: map[string]string{}}
|
||||
// baseUrl 自身可能带 ",{...}" 选项段,先截断(对应 init 中的 paramPattern)
|
||||
if st, _, ok := findParamSplit(baseUrl); ok {
|
||||
baseUrl = baseUrl[:st]
|
||||
}
|
||||
ruleUrl := mUrl
|
||||
|
||||
// ── analyzeJs:URL 中的 <js>/@js:,@result 引用前序结果 ──
|
||||
if jsPatternRe.MatchString(ruleUrl) {
|
||||
if runner == nil {
|
||||
req.Unsupported = ErrJsUnsupported
|
||||
// 移除 JS 块继续解析,便于调试接口展示其余部分
|
||||
ruleUrl = jsPatternRe.ReplaceAllString(ruleUrl, "")
|
||||
} else {
|
||||
// 对应 AnalyzeUrl.analyzeJs
|
||||
start := 0
|
||||
result := ruleUrl
|
||||
for _, g := range jsPatternRe.FindAllStringSubmatchIndex(ruleUrl, -1) {
|
||||
if g[0] > start {
|
||||
if seg := strings.TrimSpace(ruleUrl[start:g[0]]); seg != "" {
|
||||
result = strings.ReplaceAll(seg, "@result", anyToString(result))
|
||||
}
|
||||
}
|
||||
jsBody := ""
|
||||
if g[2] >= 0 {
|
||||
jsBody = ruleUrl[g[2]:g[3]]
|
||||
} else if g[4] >= 0 {
|
||||
jsBody = ruleUrl[g[4]:g[5]]
|
||||
}
|
||||
v, err := runner.Run(nil, jsBody, result, baseUrl)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
result = anyToString(v)
|
||||
start = g[1]
|
||||
}
|
||||
if len(ruleUrl) > start {
|
||||
if seg := strings.TrimSpace(ruleUrl[start:]); seg != "" {
|
||||
result = strings.ReplaceAll(seg, "@result", result)
|
||||
}
|
||||
}
|
||||
ruleUrl = result
|
||||
}
|
||||
}
|
||||
|
||||
// ── replaceKeyPageJs:{{...}} 与 <页码列表> ──
|
||||
if strings.Contains(ruleUrl, "{{") && strings.Contains(ruleUrl, "}}") {
|
||||
var subErr error
|
||||
if runner == nil {
|
||||
ra := NewRuleAnalyzer(ruleUrl, false)
|
||||
out := ra.InnerRule2("{{", "}}", func(inner string) string {
|
||||
trimmed := strings.TrimSpace(inner)
|
||||
switch trimmed {
|
||||
case "key":
|
||||
return key
|
||||
case "page":
|
||||
p := page
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
return anyToString(float64(p))
|
||||
default:
|
||||
subErr = ErrJsUnsupported
|
||||
return ""
|
||||
}
|
||||
})
|
||||
if subErr != nil {
|
||||
req.Unsupported = subErr
|
||||
} else if out != "" {
|
||||
ruleUrl = out
|
||||
}
|
||||
} else {
|
||||
// 对应 legado:{{...}} 一律按 JS 执行(key/page 为绑定变量)
|
||||
ra := NewRuleAnalyzer(ruleUrl, false)
|
||||
out := ra.InnerRule2("{{", "}}", func(inner string) string {
|
||||
v, err := runner.Run(nil, strings.TrimSpace(inner), nil, baseUrl)
|
||||
if err != nil {
|
||||
subErr = err
|
||||
return ""
|
||||
}
|
||||
return anyToString(v)
|
||||
})
|
||||
if subErr != nil {
|
||||
return req, subErr
|
||||
}
|
||||
if out != "" {
|
||||
ruleUrl = out
|
||||
}
|
||||
}
|
||||
}
|
||||
if page >= 1 {
|
||||
for _, m := range pagePatternRe.FindAllStringSubmatch(ruleUrl, -1) {
|
||||
pages := strings.Split(m[1], ",")
|
||||
idx := page
|
||||
if idx > len(pages) {
|
||||
idx = len(pages)
|
||||
}
|
||||
ruleUrl = strings.ReplaceAll(ruleUrl, m[0], strings.TrimSpace(pages[idx-1]))
|
||||
}
|
||||
}
|
||||
|
||||
// ── analyzeUrl:分离 URL 与选项 JSON ──
|
||||
ruleUrl = strings.TrimSpace(ruleUrl)
|
||||
st, end, hasOpt := findParamSplit(ruleUrl)
|
||||
urlNoOption := ruleUrl
|
||||
if hasOpt {
|
||||
urlNoOption = ruleUrl[:st]
|
||||
}
|
||||
urlNoOption = strings.TrimSpace(urlNoOption)
|
||||
finalURL := GetAbsoluteURL(baseUrl, urlNoOption)
|
||||
if b := GetBaseUrl(finalURL); b != "" {
|
||||
baseUrl = b
|
||||
}
|
||||
req.URL = finalURL
|
||||
|
||||
if hasOpt {
|
||||
optionStr := strings.TrimSpace(ruleUrl[end:])
|
||||
var option URLOption
|
||||
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
|
||||
// 宽松重试:去掉可能的前后杂字符
|
||||
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
|
||||
return req, nil
|
||||
}
|
||||
}
|
||||
switch strings.ToUpper(option.Method) {
|
||||
case "POST":
|
||||
req.Method = "POST"
|
||||
case "HEAD":
|
||||
req.Method = "HEAD"
|
||||
}
|
||||
for k, v := range option.Headers {
|
||||
req.Headers[k] = anyToString(v)
|
||||
}
|
||||
if len(option.Body) > 0 {
|
||||
var bodyStr string
|
||||
if err := json.Unmarshal(option.Body, &bodyStr); err == nil {
|
||||
req.Body = bodyStr
|
||||
} else {
|
||||
req.Body = string(option.Body)
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
req.HexBody = true
|
||||
}
|
||||
if option.WebJs != "" && req.Unsupported == nil {
|
||||
req.Unsupported = ErrWebJSUnsupported
|
||||
}
|
||||
if useWebView(option.WebView) && req.Unsupported == nil {
|
||||
req.Unsupported = ErrWebJSUnsupported
|
||||
}
|
||||
// 对应 AnalyzeUrl:option.js 在解析完成后执行,结果覆盖 url
|
||||
if option.Js != "" && req.Unsupported == nil {
|
||||
if runner == nil {
|
||||
req.Unsupported = ErrJsUnsupported
|
||||
} else if v, err := runner.Run(nil, option.Js, req.URL, baseUrl); err != nil {
|
||||
return req, err
|
||||
} else if s := anyToString(v); s != "" {
|
||||
req.URL = s
|
||||
}
|
||||
}
|
||||
// 对应 AnalyzeUrl:bodyJs 在响应后执行,结果作为 body
|
||||
if option.BodyJs != "" && req.Unsupported == nil && runner != nil {
|
||||
req.BodyJsFn = func(body string) string {
|
||||
v, err := runner.Run(nil, option.BodyJs, body, baseUrl)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return anyToString(v)
|
||||
}
|
||||
} else if option.BodyJs != "" && req.Unsupported == nil {
|
||||
req.Unsupported = ErrJsUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// ── query / body 编码(对应 analyzeUrl 尾部) ──
|
||||
//
|
||||
// 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
|
||||
if body != "" && !isJSONStr(body) && !isXMLStr(body) && req.Headers["Content-Type"] == "" {
|
||||
req.Body = encodeParams(body, req.Charset, false)
|
||||
req.IsForm = true
|
||||
} else if isJSONStr(body) && req.Headers["Content-Type"] == "" {
|
||||
req.IsJSON = true
|
||||
}
|
||||
} else if pos := strings.Index(req.URL, "?"); pos != -1 {
|
||||
req.URLNoQuery = req.URL[:pos]
|
||||
} else {
|
||||
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 {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(raw, &b); err == nil {
|
||||
return b
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s != "" && s != "false"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isJSONStr(s string) bool {
|
||||
t := strings.TrimSpace(s)
|
||||
if !(strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) {
|
||||
return false
|
||||
}
|
||||
return json.Valid([]byte(t))
|
||||
}
|
||||
|
||||
func isXMLStr(s string) bool {
|
||||
t := strings.TrimSpace(s)
|
||||
return strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">")
|
||||
}
|
||||
|
||||
// findParamSplit 对应 paramPattern \s*,\s*(?=\{) 的手动实现。
|
||||
// 返回匹配起点(含逗号前空白)与选项 JSON 起点。
|
||||
func findParamSplit(s string) (start, end int, ok bool) {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != ',' {
|
||||
continue
|
||||
}
|
||||
st := i
|
||||
for st > 0 && isSpaceByte(s[st-1]) {
|
||||
st--
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(s) && isSpaceByte(s[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) && s[j] == '{' {
|
||||
return st, j, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func isSpaceByte(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
|
||||
}
|
||||
|
||||
// encodeParams 对应 AnalyzeUrl.encodeParams。
|
||||
func encodeParams(params, charset string, isQuery bool) string {
|
||||
checkEncoded := charset == ""
|
||||
cs := charset
|
||||
if cs == "" {
|
||||
cs = "utf-8"
|
||||
}
|
||||
if isQuery && cs != "escape" {
|
||||
if encodedQuery(params) {
|
||||
return params
|
||||
}
|
||||
return queryEncode(params, cs)
|
||||
}
|
||||
var sb strings.Builder
|
||||
lenP := len(params)
|
||||
pos := 0
|
||||
for pos <= lenP {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("&")
|
||||
}
|
||||
ampOffset := strings.IndexByte(params[pos:], '&')
|
||||
if ampOffset == -1 {
|
||||
ampOffset = lenP
|
||||
} else {
|
||||
ampOffset += pos
|
||||
}
|
||||
eqOffset := strings.IndexByte(params[pos:ampOffset], '=')
|
||||
if eqOffset == -1 {
|
||||
sb.WriteString(appendEncodedStr(params[pos:ampOffset], checkEncoded, cs))
|
||||
} else {
|
||||
eqAbs := eqOffset + pos
|
||||
sb.WriteString(appendEncodedStr(params[pos:eqAbs], checkEncoded, cs))
|
||||
sb.WriteString("=")
|
||||
sb.WriteString(appendEncodedStr(params[eqAbs+1:ampOffset], checkEncoded, cs))
|
||||
}
|
||||
pos = ampOffset + 1
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// appendEncodedStr 对应 StringBuilder.appendEncoded。
|
||||
func appendEncodedStr(value string, checkEncoded bool, charset string) string {
|
||||
if checkEncoded && encodedForm(value) {
|
||||
return value
|
||||
}
|
||||
if charset == "escape" {
|
||||
return JSEscape(value)
|
||||
}
|
||||
return javaURLEncode(value, charset)
|
||||
}
|
||||
|
||||
// javaURLEncode 对应 java.net.URLEncoder.encode(value, charset):
|
||||
// 字母数字与 .-*_ 保留,空格转 +,其余按字符集字节 %XX。
|
||||
func javaURLEncode(value, charset string) string {
|
||||
enc := encoderFor(charset)
|
||||
var sb strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'),
|
||||
r == '.', r == '-', r == '*', r == '_':
|
||||
sb.WriteRune(r)
|
||||
case r == ' ':
|
||||
sb.WriteByte('+')
|
||||
default:
|
||||
for _, b := range enc(string(r)) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
sb.WriteByte('%')
|
||||
sb.WriteByte(hex[b>>4])
|
||||
sb.WriteByte(hex[b&0xF])
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// queryEncode 对应 hutool RFC3986.UNRESERVED.orNew(...) 的 query 编码:
|
||||
// 保留 notNeedEncodingQuery 集合内的字符,其余按字符集字节 %XX。
|
||||
func queryEncode(params, charset string) string {
|
||||
enc := encoderFor(charset)
|
||||
var sb strings.Builder
|
||||
for _, r := range params {
|
||||
if r < 128 && notNeedEncodingQuery[r] {
|
||||
sb.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
for _, b := range enc(string(r)) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
sb.WriteByte('%')
|
||||
sb.WriteByte(hex[b>>4])
|
||||
sb.WriteByte(hex[b&0xF])
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func encoderFor(charset string) func(string) []byte {
|
||||
if strings.EqualFold(charset, "utf-8") || strings.EqualFold(charset, "utf8") {
|
||||
utf8Enc := unicode.UTF8
|
||||
return func(s string) []byte { b, _ := utf8Enc.NewEncoder().Bytes([]byte(s)); return b }
|
||||
}
|
||||
enc, err := htmlindex.Get(charset)
|
||||
if err != nil {
|
||||
utf8Enc := unicode.UTF8
|
||||
return func(s string) []byte { b, _ := utf8Enc.NewEncoder().Bytes([]byte(s)); return b }
|
||||
}
|
||||
return func(s string) []byte { b, _ := enc.NewEncoder().Bytes([]byte(s)); return b }
|
||||
}
|
||||
@@ -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,149 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/antchfx/htmlquery"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 本文件对应 AnalyzeByXPath.kt(JsoupXpath 语义,Go 侧用 antchfx/htmlquery)。
|
||||
|
||||
type xpathAnalyzer struct {
|
||||
root *html.Node
|
||||
}
|
||||
|
||||
func newXPathAnalyzer(doc any) *xpathAnalyzer {
|
||||
return &xpathAnalyzer{root: toHTMLNode(doc)}
|
||||
}
|
||||
|
||||
// xpathResult 对应 getResult(xPath)。
|
||||
func (a *xpathAnalyzer) result(xPath string) []*html.Node {
|
||||
if a.root == nil || xPath == "" {
|
||||
return nil
|
||||
}
|
||||
return htmlquery.Find(a.root, xPath)
|
||||
}
|
||||
|
||||
// xpathNodeString 对应 JXNode.asString()。
|
||||
func xpathNodeString(n *html.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
return htmlquery.InnerText(n)
|
||||
}
|
||||
if n.Data != "" {
|
||||
return n.Data
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getElements 对应 AnalyzeByXPath.getElements。
|
||||
func (a *xpathAnalyzer) getElements(xPath string) []*html.Node {
|
||||
if xPath == "" {
|
||||
return nil
|
||||
}
|
||||
ra := NewRuleAnalyzer(xPath, false)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) == 1 {
|
||||
return a.result(rules[0])
|
||||
}
|
||||
var out []*html.Node
|
||||
var results [][]*html.Node
|
||||
for _, rl := range rules {
|
||||
temp := a.getElements(rl)
|
||||
if len(temp) > 0 {
|
||||
results = append(results, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(results[0]); i++ {
|
||||
for _, temp := range results {
|
||||
if i < len(temp) {
|
||||
out = append(out, temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, temp := range results {
|
||||
out = append(out, temp...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getStringList 对应 AnalyzeByXPath.getStringList。
|
||||
func (a *xpathAnalyzer) getStringList(xPath string) []string {
|
||||
var result []string
|
||||
ra := NewRuleAnalyzer(xPath, false)
|
||||
rules := ra.SplitRule("&&", "||", "%%")
|
||||
if len(rules) == 1 {
|
||||
for _, n := range a.result(xPath) {
|
||||
result = append(result, xpathNodeString(n))
|
||||
}
|
||||
return result
|
||||
}
|
||||
var results [][]string
|
||||
for _, rl := range rules {
|
||||
temp := a.getStringList(rl)
|
||||
if len(temp) > 0 {
|
||||
results = append(results, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if ra.ElementsType() == "%%" {
|
||||
for i := 0; i < len(results[0]); i++ {
|
||||
for _, temp := range results {
|
||||
if i < len(temp) {
|
||||
result = append(result, temp[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, temp := range results {
|
||||
result = append(result, temp...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getString 对应 AnalyzeByXPath.getString:多节点以 \n 连接。
|
||||
func (a *xpathAnalyzer) getString(rule string) string {
|
||||
ra := NewRuleAnalyzer(rule, false)
|
||||
rules := ra.SplitRule("&&", "||")
|
||||
if len(rules) == 1 {
|
||||
nodes := a.result(rule)
|
||||
if len(nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
parts[i] = xpathNodeString(n)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
var textList []string
|
||||
for _, rl := range rules {
|
||||
temp := a.getString(rl)
|
||||
if temp != "" {
|
||||
textList = append(textList, temp)
|
||||
if ra.ElementsType() == "||" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(textList, "\n")
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user