mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 12:36:37 +08:00
@@ -187,7 +187,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: true
|
cache: true
|
||||||
- name: Download web/dist
|
- name: Download web/dist
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
# 产出:
|
# 产出:
|
||||||
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
||||||
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
# 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 不做版本递增、
|
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
|
||||||
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
|
||||||
|
# 不打 release tag、不发布 GitHub Release、不部署服务器。
|
||||||
|
|
||||||
name: Beta Build
|
name: Beta Build
|
||||||
|
|
||||||
@@ -22,9 +24,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
|
||||||
BETA_VERSION_PREFIX: beta
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 1) 编译验证 + 多平台二进制产物
|
# 1) 编译验证 + 多平台二进制产物
|
||||||
@@ -32,17 +31,24 @@ jobs:
|
|||||||
test-and-build:
|
test-and-build:
|
||||||
name: Test & build artifacts
|
name: Test & build artifacts
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
binary_version: ${{ steps.version.outputs.binary_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
|
||||||
|
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
|
||||||
- name: Resolve beta version
|
- name: Resolve beta version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
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}
|
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
|
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
|
||||||
# before the Go toolchain touches the web package.
|
# before the Go toolchain touches the web package.
|
||||||
@@ -59,7 +65,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
@@ -71,13 +77,13 @@ jobs:
|
|||||||
- name: go build (host)
|
- name: go build (host)
|
||||||
run: go build ./...
|
run: go build ./...
|
||||||
|
|
||||||
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
|
||||||
- name: Build linux/amd64
|
- 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
|
- 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
|
- 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -87,7 +93,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
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:
|
docker-beta:
|
||||||
name: Build & push beta Docker image
|
name: Build & push beta Docker image
|
||||||
@@ -98,13 +105,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -115,6 +115,15 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Build & push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -123,11 +132,9 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: |
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
|
||||||
org.opencontainers.image.source=${{ github.repository }}
|
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.version.outputs.full_version }}
|
VERSION=${{ needs.test-and-build.outputs.binary_version }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
||||||
|
|||||||
@@ -81,3 +81,4 @@ tools/
|
|||||||
verify-cache/
|
verify-cache/
|
||||||
verify-data/
|
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.
|
# Multi-architecture build for MeBox.
|
||||||
#
|
#
|
||||||
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
|
# 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
|
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||||
#
|
#
|
||||||
# Build:
|
# Build:
|
||||||
@@ -25,7 +25,7 @@ COPY web/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
|
# ---- 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 TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG GOPROXY=https://proxy.golang.org,direct
|
ARG GOPROXY=https://proxy.golang.org,direct
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="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" />
|
<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>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="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" />
|
<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,224 @@
|
|||||||
|
# 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),不受影响。
|
||||||
|
|
||||||
|
## 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-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
|
||||||
|
- 播放进度按秒记忆(节流 10s 上报),跨端一致;播完自动下一章。
|
||||||
|
- 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
|
module github.com/truewhile/MeBox
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.2
|
fyne.io/systray v1.12.2
|
||||||
@@ -19,9 +19,9 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
go.uber.org/zap v1.27.0
|
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/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/sys v0.47.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -30,6 +30,12 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
|
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-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-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // 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/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -90,8 +102,8 @@ require (
|
|||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.25.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
modernc.org/libc v1.70.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 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
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 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
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/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 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
|
||||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
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 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
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/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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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/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 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
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=
|
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 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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.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 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
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 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
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 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.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 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
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=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
||||||
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||||
api.HEAD("/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 会话 + 共享密钥校验)
|
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
|
||||||
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||||
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 书源宿主浏览器的 HTTP 层测试:轮询待办 → 承载页面 → 回传 DOM。
|
||||||
|
//
|
||||||
|
// 这条链路对应真实书源的「切换线路」「用户后台」:
|
||||||
|
// java.startBrowserAwait 会阻塞在 POST /sources/:id/login 上,
|
||||||
|
// 前端必须能从 /reader/browser/pending 拿到页面,再把 DOM POST 回去。
|
||||||
|
|
||||||
|
// browserHandlerSource 构造一个用 startBrowserAwait 切换线路的书源。
|
||||||
|
func browserHandlerSource(sourceURL string) string {
|
||||||
|
loginJS := `function switchLine() {
|
||||||
|
let html = '<!DOCTYPE html><html><head></head><body><span id="serverValue">线路甲</span></body></html>';
|
||||||
|
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
|
||||||
|
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
|
||||||
|
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
|
||||||
|
}`
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": sourceURL,
|
||||||
|
"bookSourceName": "浏览器面板源",
|
||||||
|
"loginUrl": loginJS,
|
||||||
|
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(src)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBrowserRoutesForTest 挂载阅读路由 + 承载页面/资源代理(跳过鉴权)。
|
||||||
|
// 生产环境里后两个是公开路由(iframe 带不了 JWT),鉴权靠 HMAC 签名。
|
||||||
|
func registerBrowserRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||||
|
r := registerReaderRoutesForTest(cfg)
|
||||||
|
r.GET("/api/reader/browser/page", readerBrowserPageHandler(cfg))
|
||||||
|
r.GET("/api/reader/browser/asset", readerBrowserAssetHandler(cfg))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaderBrowserPanelRoutes(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
router := registerBrowserRoutesForTest(container)
|
||||||
|
|
||||||
|
// 导入书源
|
||||||
|
body, _ := json.Marshal(map[string]string{"text": browserHandlerSource("https://panel.example.com")})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||||
|
var list struct {
|
||||||
|
Sources []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"sources"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
||||||
|
if len(list.Sources) != 1 {
|
||||||
|
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
sourceID := list.Sources[0].ID
|
||||||
|
|
||||||
|
// 执行切换线路:这个请求会阻塞等待用户操作,放到后台跑
|
||||||
|
actionDone := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login",
|
||||||
|
strings.NewReader(`{"action":"switchLine()","fields":{}}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
actionDone <- rec
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 轮询待办页面
|
||||||
|
var page struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
PageURL string `json:"page_url"`
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("等待超时:未出现待办页面")
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("轮询 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var pending struct {
|
||||||
|
Pages []json.RawMessage `json:"pages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||||
|
t.Fatalf("待办返回不是 JSON: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if len(pending.Pages) > 0 {
|
||||||
|
if err := json.Unmarshal(pending.Pages[0], &page); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if page.Mode != "wait" || page.Title != "线路设置" {
|
||||||
|
t.Fatalf("待办描述异常: %+v", page)
|
||||||
|
}
|
||||||
|
if page.SourceID != sourceID {
|
||||||
|
t.Fatalf("待办应带书源 ID: %+v", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 承载页面:签名正确应返回 HTML,签名错误应 403
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, page.PageURL, nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("承载页面 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `id="serverValue"`) {
|
||||||
|
t.Fatalf("承载页面内容异常: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
// 服务端注入的 DOM 回传脚本必须在(父窗口靠它取回用户操作后的页面)
|
||||||
|
if !strings.Contains(w.Body.String(), "data-mebox-browser-bridge") {
|
||||||
|
t.Fatalf("承载页面缺少 DOM 回传脚本: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/page?id="+page.ID+"&s=forged", nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("伪造签名应 403,实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回传用户操作后的 DOM(模拟点了 √)
|
||||||
|
result, _ := json.Marshal(map[string]any{
|
||||||
|
"id": page.ID,
|
||||||
|
"body": `<html><body><span id="serverValue">线路乙</span></body></html>`,
|
||||||
|
})
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(string(result)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("回传结果 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阻塞的登录动作应被放行
|
||||||
|
select {
|
||||||
|
case rec := <-actionDone:
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("登录动作 status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||||
|
if !res.OK {
|
||||||
|
t.Fatalf("动作未成功: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("回传后阻塞未解除")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线路应已写进源变量
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||||
|
var info struct {
|
||||||
|
Variable string `json:"variable"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &info)
|
||||||
|
if !strings.Contains(info.Variable, "线路乙") {
|
||||||
|
t.Fatalf("线路未写入源变量: %q", info.Variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成后待办清空
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||||
|
var after struct {
|
||||||
|
Pages []json.RawMessage `json:"pages"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &after)
|
||||||
|
if len(after.Pages) != 0 {
|
||||||
|
t.Fatalf("完成后待办未清理: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaderBrowserResultRejectsUnknownPage(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
router := registerBrowserRoutesForTest(container)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result",
|
||||||
|
strings.NewReader(`{"id":"not-a-real-page","body":"x"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("未知待办应 400,实际 %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaderBrowserResultRequiresID(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
router := registerBrowserRoutesForTest(container)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("缺少 id 应 400,实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReaderBrowserPageBadQuery 承载地址参数缺失/非法时不应 500。
|
||||||
|
func TestReaderBrowserPageBadQuery(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
router := registerBrowserRoutesForTest(container)
|
||||||
|
|
||||||
|
for _, target := range []string{"/api/reader/browser/page", "/api/reader/browser/page?id=x",
|
||||||
|
"/api/reader/browser/asset", "/api/reader/browser/asset?id=x&u=!!!&s=y"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("%s 应 403,实际 %d", target, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 合法 base64 但不匹配的签名
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
q := url.Values{"id": {"x"}, "u": {"aHR0cHM6Ly9leGFtcGxlLmNvbS8"}, "s": {"deadbeef"}}
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/asset?"+q.Encode(), nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("签名不匹配应 403,实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReaderLocalAudioStream 本地有声书音频流端点:
|
||||||
|
// 签名鉴权 + Range 透传(播放器拖进度靠它)。
|
||||||
|
func TestReaderLocalAudioStream(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||||
|
router.HEAD("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||||
|
|
||||||
|
payload := []byte("0123456789abcdefghij")
|
||||||
|
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||||
|
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||||
|
|
||||||
|
// 全量拉取
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w.Body.String() != string(payload) {
|
||||||
|
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range 请求要能拖进度
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||||
|
req.Header.Set("Range", "bytes=2-5")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "2345" {
|
||||||
|
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 篡改签名应被拒绝
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件不存在 → 404(而不是 500)
|
||||||
|
missing := container.Reader.LocalAudioURL("book-1", filepath.Join(t.TempDir(), "nope.mp3"))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, missing, nil))
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("缺失文件 status=%d,期望 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReaderAudioTranscodeRoute 转码端点:签名鉴权,命中缓存后按 Range 下发。
|
||||||
|
// 用预置的缓存文件代替真实转码,避免测试依赖 ffmpeg。
|
||||||
|
func TestReaderAudioTranscodeRoute(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
container.Cfg.App.DataDir = t.TempDir() // 转码缓存落在临时目录,别写进仓库
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||||
|
router.HEAD("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||||
|
|
||||||
|
const bookID = "book-1"
|
||||||
|
source := `D:\media\斗破苍穹\001.wma`
|
||||||
|
payload := []byte("ID3FAKEMP3PAYLOAD0123456789")
|
||||||
|
|
||||||
|
cachePath, err := container.Reader.AudioTranscodeCachePath(bookID, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cachePath, payload, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := container.Reader.AudioTranscodeURL(bookID, source)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w.Body.String() != string(payload) {
|
||||||
|
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖进度靠 Range
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||||
|
req.Header.Set("Range", "bytes=3-7")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "FAKEM" {
|
||||||
|
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 篡改签名应被拒绝
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换一本书(签名不匹配)同样拒绝
|
||||||
|
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("换书后 status=%d,期望 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReaderAudioTranscodeRouteWithoutFFmpeg 没装 ffmpeg 且无缓存时返回明确错误,
|
||||||
|
// 而不是空响应(前端据此提示用户去装 ffmpeg)。
|
||||||
|
func TestReaderAudioTranscodeRouteWithoutFFmpeg(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
container.Cfg.App.DataDir = t.TempDir()
|
||||||
|
container.Cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||||
|
|
||||||
|
signed := container.Reader.AudioTranscodeURL("book-1", `D:\media\001.wma`)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status=%d,期望 502", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "ffmpeg") {
|
||||||
|
t.Fatalf("错误正文应提到 ffmpeg: %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReaderLocalAudioRejectsOtherBook 换一本书的签名不通用:防止拿到别人的音频地址。
|
||||||
|
func TestReaderLocalAudioRejectsOtherBook(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||||
|
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||||
|
if forged == signed {
|
||||||
|
t.Fatal("测试用例未改写 book 参数")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("换书后签名应失效,status=%d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,775 @@
|
|||||||
|
// 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/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.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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
var body struct {
|
||||||
|
ChapterIndex int `json:"chapter_index"`
|
||||||
|
Pos int `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
|
||||||
|
}
|
||||||
|
userID := c.GetString(middleware.CtxUserID)
|
||||||
|
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, body.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)
|
registerAuthedDLNAControlRoutes(authed, svc)
|
||||||
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
|
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
|
||||||
registerAuthedPlaybackExtraRoutes(authed, svc)
|
registerAuthedPlaybackExtraRoutes(authed, svc)
|
||||||
|
registerReaderRoutes(authed, svc)
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-1
@@ -3,14 +3,66 @@
|
|||||||
package helper
|
package helper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"compress/gzip"
|
||||||
|
"compress/zlib"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
|
// 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"
|
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:
|
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
|
||||||
// - timeout (seconds, defaults to 15)
|
// - timeout (seconds, defaults to 15)
|
||||||
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
|
// - 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.
|
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
|
||||||
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
// 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 {
|
func HTTPHeaderPresets() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"User-Agent": defaultUserAgent,
|
"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": "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-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Upgrade-Insecure-Requests": "1",
|
"Upgrade-Insecure-Requests": "1",
|
||||||
"Sec-Fetch-Dest": "document",
|
"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,10 @@ func AllModels() []interface{} {
|
|||||||
&StrmDirCache{},
|
&StrmDirCache{},
|
||||||
&ScrapeTask{},
|
&ScrapeTask{},
|
||||||
&EmbyMount{},
|
&EmbyMount{},
|
||||||
|
&ReaderBookSource{},
|
||||||
|
&ReaderSourceState{},
|
||||||
|
&ReaderBook{},
|
||||||
|
&ReaderChapter{},
|
||||||
|
&ReaderReplaceRule{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
StrmDirCache *StrmDirCacheRepository
|
||||||
ScrapeTask *ScrapeTaskRepository
|
ScrapeTask *ScrapeTaskRepository
|
||||||
EmbyMount *EmbyMountRepository
|
EmbyMount *EmbyMountRepository
|
||||||
|
Reader *ReaderRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||||
@@ -68,5 +69,6 @@ func New(db *gorm.DB) *Container {
|
|||||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||||
EmbyMount: &EmbyMountRepository{db: db},
|
EmbyMount: &EmbyMountRepository{db: db},
|
||||||
|
Reader: &ReaderRepository{db: db},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-59
@@ -12,96 +12,52 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// encPrefix tags ciphertext rows so we can tell them apart from legacy
|
// encPrefix tags ciphertext rows so we can tell them apart from legacy
|
||||||
// plaintext values.
|
// plaintext values. Kept as an alias of the shared helper's prefix so both
|
||||||
const encPrefix = "enc:v1:"
|
// implementations stay wire-compatible.
|
||||||
|
const encPrefix = helper.EncPrefix
|
||||||
|
|
||||||
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
|
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
|
||||||
// secret (the JWT secret).
|
// 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 {
|
type CryptoService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
aead cipher.AEAD
|
cipher *helper.SecretCipher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCryptoService derives a 256-bit key from the given secret via
|
// NewCryptoService derives a 256-bit key from the given secret via
|
||||||
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
|
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
|
||||||
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
|
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
|
||||||
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
|
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
|
||||||
c := &CryptoService{log: log}
|
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
|
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
|
||||||
// Empty inputs round-trip unchanged.
|
// Empty inputs round-trip unchanged.
|
||||||
func (c *CryptoService) Encrypt(plain string) string {
|
func (c *CryptoService) Encrypt(plain string) string {
|
||||||
if plain == "" || c.aead == nil {
|
return c.cipher.Encrypt(plain)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
||||||
// (no prefix) are returned unchanged.
|
// (no prefix) are returned unchanged.
|
||||||
func (c *CryptoService) Decrypt(value string) string {
|
func (c *CryptoService) Decrypt(value string) string {
|
||||||
if c == nil || value == "" || c.aead == nil {
|
return c.cipher.Decrypt(value)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEncrypted returns true if value carries the encrypted prefix.
|
// IsEncrypted returns true if value carries the encrypted prefix.
|
||||||
func (c *CryptoService) IsEncrypted(value string) bool {
|
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
|
// 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.
|
// ErrRootMutation protects configured roots such as /media and /downloads.
|
||||||
var ErrRootMutation = errors.New("refusing to mutate an allowed root")
|
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
|
// 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 items sorted by (dir-first, path). Recursive listing is capped by
|
||||||
// maxEntries to avoid accidentally walking huge NAS trees from the UI.
|
// 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 {
|
func hardlinksUnsupported(t *testing.T, root string) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
src := filepath.Join(root, "hardlink-probe-src")
|
src := filepath.Join(root, "hardlink-probe-src")
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSaveAudioConfigPersistsCredits 听书跳过片头/片尾设置要落库,且只能改自己的书。
|
||||||
|
func TestSaveAudioConfigPersistsCredits(t *testing.T) {
|
||||||
|
svc, repos := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
book := &model.ReaderBook{UserID: "u1", Name: "宠魅", Type: 1}
|
||||||
|
if err := repos.Reader.CreateBook(ctx, book); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 30, 15); err != nil {
|
||||||
|
t.Fatalf("保存听书设置失败: %v", err)
|
||||||
|
}
|
||||||
|
got, err := svc.GetBook(ctx, book.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.OpenCredits != 30 || got.CloseCredits != 15 {
|
||||||
|
t.Fatalf("片头/片尾 = %d/%d,期望 30/15", got.OpenCredits, got.CloseCredits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 是合法值(不跳过),必须能写回
|
||||||
|
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 0, 0); err != nil {
|
||||||
|
t.Fatalf("清零失败: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ = svc.GetBook(ctx, book.ID); got.OpenCredits != 0 || got.CloseCredits != 0 {
|
||||||
|
t.Fatalf("清零后 = %d/%d,期望 0/0", got.OpenCredits, got.CloseCredits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, -1, 0); err == nil {
|
||||||
|
t.Fatal("负数应被拒绝")
|
||||||
|
}
|
||||||
|
if err := svc.SaveAudioConfig(ctx, "other", book.ID, 10, 10); err == nil {
|
||||||
|
t.Fatal("他人书架应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// 本地/远端有声书的按需转码:源文件是浏览器解不了的格式(典型是 WMA/ASF)时,
|
||||||
|
// 用 ffmpeg 转成 mp3 落到缓存目录,再按 Range 下发。
|
||||||
|
//
|
||||||
|
// 为什么落盘而不是实时流式输出:有声书必须能拖动进度,而 http.ServeContent 的
|
||||||
|
// Range 支持要求可随机读取的文件;实时管道没有长度信息,一旦 seek 就废掉。
|
||||||
|
// 一章通常几 MB,转一次几秒,之后同一章秒开。
|
||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audioTranscodeFlight 同一章节并发命中时只跑一次 ffmpeg(按缓存路径去重)。
|
||||||
|
var audioTranscodeFlight singleflight.Group
|
||||||
|
|
||||||
|
// 转码互斥表:singleflight 只保证同一 key 不重复执行,prune 与写文件仍需串行。
|
||||||
|
var audioTranscodeMu sync.Mutex
|
||||||
|
|
||||||
|
const (
|
||||||
|
// audioTranscodeDirName 缓存目录名,位于 cache.cache_dir 之下。
|
||||||
|
audioTranscodeDirName = "reader-audio"
|
||||||
|
// maxAudioTranscodeCacheBytes 转码缓存上限,超出按修改时间淘汰最旧的。
|
||||||
|
maxAudioTranscodeCacheBytes = 4 << 30
|
||||||
|
// audioTranscodeKeepRatio 触发淘汰后回落到上限的比例,避免每次写入都淘汰。
|
||||||
|
audioTranscodeKeepRatio = 0.9
|
||||||
|
// maxAudioTranscodeDuration 单章转码超时。
|
||||||
|
maxAudioTranscodeDuration = 15 * time.Minute
|
||||||
|
// audioTranscodeBitrate 语音内容 96k 足够,体积约为原 WMA 的两倍以内。
|
||||||
|
audioTranscodeBitrate = "96k"
|
||||||
|
)
|
||||||
|
|
||||||
|
// needsTranscodeAudioExt 明确需要转码的容器/编码(浏览器都无法直接解码)。
|
||||||
|
// 只列已知有问题的:未列出的格式维持原样直出,避免把本来能播的流也拖去转码。
|
||||||
|
var needsTranscodeAudioExt = map[string]bool{
|
||||||
|
".wma": true, ".asf": true, ".wmv": true, ".ape": true, ".wv": true,
|
||||||
|
".ac3": true, ".dts": true, ".amr": true, ".tta": true, ".dsf": true, ".dff": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioSourceExt 取音频地址的扩展名(小写带点)。
|
||||||
|
//
|
||||||
|
// 既要认本地路径(D:\x\a.wma),也要认带 query 的远端地址
|
||||||
|
// (…/video.wma?acct=…)——后者直接 filepath.Ext 会把 query 一起算进去。
|
||||||
|
func audioSourceExt(source string) string {
|
||||||
|
raw := strings.TrimSpace(source)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// scheme 长度 >1 才算真 URL:Windows 盘符(D:\…)会被 url.Parse 当成单字符 scheme
|
||||||
|
if u, err := url.Parse(raw); err == nil && len(u.Scheme) > 1 {
|
||||||
|
return strings.ToLower(filepath.Ext(u.Path))
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Ext(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsAudioTranscode 判断该音频是否必须转码后才能给浏览器播放。
|
||||||
|
func needsAudioTranscode(source string) bool {
|
||||||
|
return needsTranscodeAudioExt[audioSourceExt(source)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpegBinary 解析可用的 ffmpeg 可执行文件;找不到返回空串。
|
||||||
|
func (s *ReaderService) ffmpegBinary() string {
|
||||||
|
path := strings.TrimSpace(s.cfg.App.FFmpegPath)
|
||||||
|
if path == "" {
|
||||||
|
path = "ffmpeg"
|
||||||
|
}
|
||||||
|
resolved, err := exec.LookPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrAudioTranscodeUnavailable 服务器没有 ffmpeg,无法转码该格式。
|
||||||
|
var ErrAudioTranscodeUnavailable = errors.New("服务器未安装 ffmpeg")
|
||||||
|
|
||||||
|
// transcodeMissingFFmpegError 给前端一条能直接照做的提示。
|
||||||
|
func transcodeMissingFFmpegError(source string) error {
|
||||||
|
ext := strings.TrimPrefix(audioSourceExt(source), ".")
|
||||||
|
if ext == "" {
|
||||||
|
ext = "该"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("这个音频是 %s 格式,浏览器无法直接播放,需要服务器转码;%w,请在设置里配置 app.ffmpeg_path 或安装 ffmpeg",
|
||||||
|
strings.ToUpper(ext), ErrAudioTranscodeUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 签名(与本地音频同一套:<audio src> 带不上 JWT) ─────────────────────
|
||||||
|
|
||||||
|
func (s *ReaderService) signAudioTranscode(bookID, source string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
|
||||||
|
mac.Write([]byte(bookID + "|transcode|" + source))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioTranscodeURL 需要转码的音频地址(签名代理)。
|
||||||
|
func (s *ReaderService) AudioTranscodeURL(bookID, source string) string {
|
||||||
|
return "/api/reader/audio/transcode?b=" + url.QueryEscape(bookID) +
|
||||||
|
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(source)) +
|
||||||
|
"&s=" + s.signAudioTranscode(bookID, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAudioTranscodeURL 校验签名并还原原始音频地址(本地路径或远端 URL)。
|
||||||
|
func (s *ReaderService) VerifyAudioTranscodeURL(bookID, encoded, sig string) (string, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("转码地址解码失败")
|
||||||
|
}
|
||||||
|
expect := s.signAudioTranscode(bookID, string(raw))
|
||||||
|
if !hmac.Equal([]byte(expect), []byte(sig)) {
|
||||||
|
return "", fmt.Errorf("转码签名校验失败")
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 缓存与转码 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ReaderService) audioTranscodeDir() (string, error) {
|
||||||
|
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
|
||||||
|
if base == "" {
|
||||||
|
base = filepath.Join(s.cfg.App.DataDir, "cache")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, audioTranscodeDirName)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("创建转码缓存目录失败: %w", err)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioTranscodeCachePath 缓存文件名由(书 + 源地址)哈希决定:同一章重复播放直接命中。
|
||||||
|
func audioTranscodeCachePath(dir, bookID, source string) string {
|
||||||
|
sum := sha256.Sum256([]byte(bookID + "|" + source))
|
||||||
|
return filepath.Join(dir, hex.EncodeToString(sum[:16])+".mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioTranscodeCachePath 返回该音轨的转码缓存文件路径(不触发转码),
|
||||||
|
// 供测试与运维排查使用。
|
||||||
|
func (s *ReaderService) AudioTranscodeCachePath(bookID, source string) (string, error) {
|
||||||
|
dir, err := s.audioTranscodeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return audioTranscodeCachePath(dir, bookID, source), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTranscodedAudio 确保该音频已有转码结果,返回可 Range 下发的 mp3 路径。
|
||||||
|
func (s *ReaderService) EnsureTranscodedAudio(ctx context.Context, bookID, source string) (string, error) {
|
||||||
|
if strings.TrimSpace(source) == "" {
|
||||||
|
return "", fmt.Errorf("缺少音频地址")
|
||||||
|
}
|
||||||
|
dir, err := s.audioTranscodeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dst := audioTranscodeCachePath(dir, bookID, source)
|
||||||
|
if ok := touchCachedAudio(dst); ok {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
if s.ffmpegBinary() == "" {
|
||||||
|
return "", transcodeMissingFFmpegError(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err, _ := audioTranscodeFlight.Do(dst, func() (any, error) {
|
||||||
|
// 排队期间别的请求可能已经转好了
|
||||||
|
if ok := touchCachedAudio(dst); ok {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
if err := s.transcodeAudioFile(ctx, bookID, source, dst, dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path, _ := v.(string)
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("转码结果不可用")
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchCachedAudio 命中缓存时刷新访问时间,作为 LRU 依据。
|
||||||
|
func touchCachedAudio(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() || info.Size() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_ = os.Chtimes(path, now, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcodeAudioFile 跑一次 ffmpeg 并原子落盘(先写 .part 再 rename)。
|
||||||
|
func (s *ReaderService) transcodeAudioFile(ctx context.Context, bookID, source, dst, dir string) error {
|
||||||
|
ffmpeg := s.ffmpegBinary()
|
||||||
|
if ffmpeg == "" {
|
||||||
|
return transcodeMissingFFmpegError(source)
|
||||||
|
}
|
||||||
|
remote := isRemoteMediaURL(source)
|
||||||
|
if !remote {
|
||||||
|
if info, err := os.Stat(source); err != nil || info.IsDir() {
|
||||||
|
return fmt.Errorf("音频文件已丢失")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转码与淘汰串行,避免边写边删
|
||||||
|
audioTranscodeMu.Lock()
|
||||||
|
pruneAudioTranscodeCache(dir, maxAudioTranscodeCacheBytes)
|
||||||
|
audioTranscodeMu.Unlock()
|
||||||
|
|
||||||
|
tmp := dst + ".part"
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
|
||||||
|
headers := s.transcodeInputHeaders(ctx, bookID, source)
|
||||||
|
// 客户端断开不该杀掉已开始的转码:产物对下次播放仍然有用
|
||||||
|
runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), maxAudioTranscodeDuration)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, ffmpeg, buildFFmpegAudioArgs(source, tmp, headers)...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if runCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("转码超时(超过 %s)", maxAudioTranscodeDuration)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("转码失败: %v: %s", err, truncateForError(string(out), 300))
|
||||||
|
}
|
||||||
|
info, err := os.Stat(tmp)
|
||||||
|
if err != nil || info.Size() == 0 {
|
||||||
|
return fmt.Errorf("转码没有产生有效输出")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, dst); err != nil {
|
||||||
|
return fmt.Errorf("写入转码缓存失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcodeInputHeaders 远端音源要带的请求头:浏览器 UA 预设 + 书源请求头 + 登录态。
|
||||||
|
// 本机 /api/strm 这类内网地址不需要,但书源直链类的有声书要靠它才能取到。
|
||||||
|
func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, source string) map[string]string {
|
||||||
|
if !isRemoteMediaURL(source) || s.repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
headers := map[string]string{}
|
||||||
|
for k, v := range helper.HTTPHeaderPresets() {
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
book, err := s.repo.GetBook(ctx, bookID)
|
||||||
|
if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
if src, err := s.repo.GetSourceByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
|
||||||
|
var extra map[string]any
|
||||||
|
if json.Unmarshal([]byte(src.Header), &extra) == nil {
|
||||||
|
for k, v := range extra {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state := s.newSourceState(ctx, book.Origin)
|
||||||
|
for k, v := range state.LoginHeaderMap() {
|
||||||
|
if !strings.EqualFold(k, "cookie") {
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ck := state.CookieForRequest(source); ck != "" {
|
||||||
|
headers["Cookie"] = ck
|
||||||
|
}
|
||||||
|
if headers["Referer"] == "" {
|
||||||
|
headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/"
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFFmpegAudioArgs 组装音频转码参数(纯函数,便于测试)。
|
||||||
|
// 只取第一条音频流:WMA 常把专辑封面挂在视频流上,-vn 一并丢掉。
|
||||||
|
func buildFFmpegAudioArgs(source, output string, headers map[string]string) []string {
|
||||||
|
args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y"}
|
||||||
|
if len(headers) > 0 {
|
||||||
|
keys := make([]string, 0, len(headers))
|
||||||
|
for k := range headers {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(headers[k])
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
args = append(args, "-headers", b.String())
|
||||||
|
}
|
||||||
|
return append(args,
|
||||||
|
"-i", source,
|
||||||
|
"-vn", "-map", "0:a:0",
|
||||||
|
"-c:a", "libmp3lame", "-b:a", audioTranscodeBitrate,
|
||||||
|
"-f", "mp3", output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneAudioTranscodeCache 缓存超过上限时按访问时间淘汰,回落到上限的 90%。
|
||||||
|
func pruneAudioTranscodeCache(dir string, maxBytes int64) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type cacheItem struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
}
|
||||||
|
items := make([]cacheItem, 0, len(entries))
|
||||||
|
var total int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".mp3") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, cacheItem{filepath.Join(dir, e.Name()), info.Size(), info.ModTime()})
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
if total <= maxBytes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].mod.Before(items[j].mod) })
|
||||||
|
target := int64(float64(maxBytes) * audioTranscodeKeepRatio)
|
||||||
|
for _, it := range items {
|
||||||
|
if total <= target {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if os.Remove(it.path) == nil {
|
||||||
|
total -= it.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateForError 截断 ffmpeg 输出,避免把整段 stderr 塞进响应。
|
||||||
|
func truncateForError(s string, limit int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) <= limit {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:limit] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAudioSourceExt 音频地址取扩展名:本地路径、带 query 的远端地址、
|
||||||
|
// Windows 盘符(会被 url.Parse 当成单字符 scheme)都要认对。
|
||||||
|
func TestAudioSourceExt(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{`D:\media\斗破苍穹\001-250\001.wma`, ".wma"},
|
||||||
|
{"/media/books/01.WMA", ".wma"},
|
||||||
|
{"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x&path=y", ".wma"},
|
||||||
|
{"https://cdn.example.com/a/b.mp3?token=1#frag", ".mp3"},
|
||||||
|
{"https://cdn.example.com/stream", ""},
|
||||||
|
{"/media/books/01.m4a", ".m4a"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := audioSourceExt(tc.in); got != tc.want {
|
||||||
|
t.Errorf("audioSourceExt(%q) = %q,期望 %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNeedsAudioTranscode 只对浏览器确定解不了的格式转码,其余维持直出。
|
||||||
|
func TestNeedsAudioTranscode(t *testing.T) {
|
||||||
|
need := []string{
|
||||||
|
`D:\media\001.wma`,
|
||||||
|
"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x",
|
||||||
|
"/media/01.asf",
|
||||||
|
"/media/01.ape",
|
||||||
|
}
|
||||||
|
for _, s := range need {
|
||||||
|
if !needsAudioTranscode(s) {
|
||||||
|
t.Errorf("%q 应需要转码", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
direct := []string{
|
||||||
|
"/media/01.mp3", "/media/01.m4a", "/media/01.m4b", "/media/01.flac",
|
||||||
|
"/media/01.ogg", "/media/01.opus", "/media/01.wav",
|
||||||
|
"https://cdn.example.com/stream", "",
|
||||||
|
}
|
||||||
|
for _, s := range direct {
|
||||||
|
if needsAudioTranscode(s) {
|
||||||
|
t.Errorf("%q 不该被转码", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioTranscodeURLSignature 转码地址的签名可校验、改一处即失效。
|
||||||
|
func TestAudioTranscodeURLSignature(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
source := `D:\media\斗破苍穹\001-250\001.wma`
|
||||||
|
|
||||||
|
raw := svc.AudioTranscodeURL("book-1", source)
|
||||||
|
if !strings.HasPrefix(raw, "/api/reader/audio/transcode?b=book-1&u=") {
|
||||||
|
t.Fatalf("转码地址格式不对: %q", raw)
|
||||||
|
}
|
||||||
|
encoded, sig := queryParam(t, raw, "u"), queryParam(t, raw, "s")
|
||||||
|
got, err := svc.VerifyAudioTranscodeURL("book-1", encoded, sig)
|
||||||
|
if err != nil || got != source {
|
||||||
|
t.Fatalf("签名校验失败: %v / %q", err, got)
|
||||||
|
}
|
||||||
|
if _, err := svc.VerifyAudioTranscodeURL("book-1", encoded, "deadbeef"); err == nil {
|
||||||
|
t.Fatal("错误签名应校验失败")
|
||||||
|
}
|
||||||
|
if _, err := svc.VerifyAudioTranscodeURL("book-2", encoded, sig); err == nil {
|
||||||
|
t.Fatal("换一本书后签名应失效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioTranscodeCachePath 缓存名对(书 + 源地址)稳定且互不冲突。
|
||||||
|
func TestAudioTranscodeCachePath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
||||||
|
b := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("同一输入应得到同一缓存路径: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(a, ".mp3") {
|
||||||
|
t.Fatalf("缓存文件应为 mp3: %q", a)
|
||||||
|
}
|
||||||
|
for _, other := range []string{
|
||||||
|
audioTranscodeCachePath(dir, "book-2", "/x/001.wma"),
|
||||||
|
audioTranscodeCachePath(dir, "book-1", "/x/002.wma"),
|
||||||
|
} {
|
||||||
|
if other == a {
|
||||||
|
t.Fatalf("不同书/不同章节不该共用缓存: %q", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureTranscodedAudioWithoutFFmpeg 没装 ffmpeg 时要给出可照做的错误。
|
||||||
|
func TestEnsureTranscodedAudioWithoutFFmpeg(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||||
|
|
||||||
|
_, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", `D:\media\001.wma`)
|
||||||
|
if !errors.Is(err, ErrAudioTranscodeUnavailable) {
|
||||||
|
t.Fatalf("err = %v,应包含 ErrAudioTranscodeUnavailable", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "WMA") || !strings.Contains(err.Error(), "ffmpeg") {
|
||||||
|
t.Fatalf("错误信息应说明格式与解决办法,实际 %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureTranscodedAudioUsesCache 已有转码结果时直接命中缓存,不依赖 ffmpeg。
|
||||||
|
func TestEnsureTranscodedAudioUsesCache(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||||
|
|
||||||
|
source := `D:\media\001.wma`
|
||||||
|
dir, err := svc.audioTranscodeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := audioTranscodeCachePath(dir, "book-1", source)
|
||||||
|
if err := os.WriteFile(want, []byte("MP3DATA"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("命中缓存时不该报错: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("缓存路径 = %q,期望 %q", got, want)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(got)
|
||||||
|
if err != nil || string(data) != "MP3DATA" {
|
||||||
|
t.Fatalf("缓存内容被改动了: %q / %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildFFmpegAudioArgs ffmpeg 参数:输入输出位置正确,丢掉视频流,
|
||||||
|
// 强制 mp3 编码,请求头按 key 排序保证可复现。
|
||||||
|
func TestBuildFFmpegAudioArgs(t *testing.T) {
|
||||||
|
args := buildFFmpegAudioArgs("/in/01.wma", "/out/01.mp3", nil)
|
||||||
|
if args[0] != "-hide_banner" || args[len(args)-1] != "/out/01.mp3" {
|
||||||
|
t.Fatalf("参数首尾不对: %v", args)
|
||||||
|
}
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{"-i /in/01.wma", "-vn", "-map 0:a:0", "-c:a libmp3lame", "-f mp3"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("参数缺少 %q: %v", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withHeaders := buildFFmpegAudioArgs("u", "o", map[string]string{
|
||||||
|
"User-Agent": "mebox", "Cookie": "a=1",
|
||||||
|
})
|
||||||
|
idx := indexOf(withHeaders, "-headers")
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("应带上 -headers: %v", withHeaders)
|
||||||
|
}
|
||||||
|
headers := withHeaders[idx+1]
|
||||||
|
if !strings.HasPrefix(headers, "Cookie: a=1\r\n") || !strings.Contains(headers, "User-Agent: mebox\r\n") {
|
||||||
|
t.Fatalf("请求头内容或顺序不对: %q", headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneAudioTranscodeCache 超过上限时按访问时间淘汰最旧的,落到 90%。
|
||||||
|
func TestPruneAudioTranscodeCache(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
base := time.Now().Add(-time.Hour)
|
||||||
|
sizes := []int64{400, 400, 400}
|
||||||
|
names := []string{"old.mp3", "mid.mp3", "new.mp3"}
|
||||||
|
var total int64
|
||||||
|
for i, name := range names {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, sizes[i]), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := base.Add(time.Duration(i) * time.Minute)
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, name), ts, ts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total += sizes[i]
|
||||||
|
}
|
||||||
|
// 上限设为总量的一半:必须淘汰
|
||||||
|
pruneAudioTranscodeCache(dir, total/2)
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "old.mp3")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("最旧的缓存应被删除,stat err = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "new.mp3")); err != nil {
|
||||||
|
t.Errorf("最新的缓存应保留: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalAudioTrackWithoutFFmpeg localAudioTrack 在需要转码但没 ffmpeg 时,
|
||||||
|
// 直接给出可读的错误,而不是返回一个注定播不了的地址。
|
||||||
|
func TestLocalAudioTrackWithoutFFmpeg(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||||
|
|
||||||
|
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
||||||
|
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.wma`}
|
||||||
|
_, transcoding, err := svc.localAudioTrack(book, ch)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺 ffmpeg 时应报错")
|
||||||
|
}
|
||||||
|
if transcoding {
|
||||||
|
t.Error("报错时不该标记为转码中")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ffmpeg") {
|
||||||
|
t.Fatalf("错误信息应提到 ffmpeg: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalAudioTrackPlayableStaysDirect 浏览器能播的格式不转码,仍走本地音频端点。
|
||||||
|
func TestLocalAudioTrackPlayableStaysDirect(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
||||||
|
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.mp3`}
|
||||||
|
|
||||||
|
track, transcoding, err := svc.localAudioTrack(book, ch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mp3 不该报错: %v", err)
|
||||||
|
}
|
||||||
|
if transcoding {
|
||||||
|
t.Error("mp3 不该标记为转码中")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
|
||||||
|
t.Fatalf("mp3 应走本地音频端点: %q", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryParam 取查询参数(签名用例共用)。
|
||||||
|
func queryParam(t *testing.T, raw, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u.Query().Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(items []string, want string) int {
|
||||||
|
for i, v := range items {
|
||||||
|
if v == want {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:详情 init 链式取值 与 书籍类型翻译 的回归测试。
|
||||||
|
//
|
||||||
|
// 背景(光遇聚合):
|
||||||
|
// - ruleBookInfo.init 是 `<js>…</js>$.data` 组合规则,它的结果会成为后续
|
||||||
|
// name/author/tocUrl 的解析内容(对应 legado
|
||||||
|
// `analyzeRule.setContent(analyzeRule.getElement(infoRule.init))`)。
|
||||||
|
// MeBox 早期把 init 结果当成 JSON 解析 map 再按写死的键名取值,导致
|
||||||
|
// 详情页「书名/作者为空、目录 0 章」。
|
||||||
|
// - 书源在目录规则里用 legado 的 BookType 位掩码给 book.type 赋值
|
||||||
|
// (8=文本 32=音频 64=图片 4=视频),而 MeBox 用 0/1/2/3,必须换算,
|
||||||
|
// 否则听书源会被当成文本,正文渲染成一串裸 URL。
|
||||||
|
|
||||||
|
// TestBookInfoInitResultBecomesContent init 的结果要成为后续字段的解析内容。
|
||||||
|
func TestBookInfoInitResultBecomesContent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/detail":
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"book_name":"宠魅","author":"某作者","thumb_url":"https://img.example.com/c.jpg","toc_url":"https://example.com/toc"}}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`<html><body>book page</body></html>`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": srv.URL,
|
||||||
|
"bookSourceName": "init 链式测试源",
|
||||||
|
"ruleBookInfo": map[string]any{
|
||||||
|
// 组合规则:先跑 JS 取详情接口,再用 $.data 取出对象
|
||||||
|
"init": fmt.Sprintf(`<js>java.ajax(%q)</js>$.data`, srv.URL+"/detail"),
|
||||||
|
"name": "$.book_name",
|
||||||
|
"author": "$.author",
|
||||||
|
"coverUrl": "$.thumb_url",
|
||||||
|
"tocUrl": "$.toc_url",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(src)
|
||||||
|
sourceID := prepareLoginSource(t, svc, string(out))
|
||||||
|
|
||||||
|
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取详情失败: %v", err)
|
||||||
|
}
|
||||||
|
if info.Name != "宠魅" {
|
||||||
|
t.Fatalf("书名 = %q(init 结果未成为解析内容)", info.Name)
|
||||||
|
}
|
||||||
|
if info.Author != "某作者" {
|
||||||
|
t.Fatalf("作者 = %q", info.Author)
|
||||||
|
}
|
||||||
|
if info.CoverURL != "https://img.example.com/c.jpg" {
|
||||||
|
t.Fatalf("封面 = %q", info.CoverURL)
|
||||||
|
}
|
||||||
|
if info.TocURL != "https://example.com/toc" {
|
||||||
|
t.Fatalf("目录地址 = %q", info.TocURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeBookType 把 legado 的 BookType 位掩码换算成 MeBox 的 0/1/2/3。
|
||||||
|
func TestNormalizeBookType(t *testing.T) {
|
||||||
|
cases := map[int]int{
|
||||||
|
0: 0, // 未知/默认 → 文本
|
||||||
|
8: 0, // text
|
||||||
|
32: 1, // audio(听书源默认值)
|
||||||
|
64: 2, // image(漫画源默认值)
|
||||||
|
4: 3, // video(短剧源默认值)
|
||||||
|
1: 0, // 苹果端遗留值不再是音频
|
||||||
|
2: 0,
|
||||||
|
// 组合位:按 音频 > 图片 > 视频 > 文本 的优先级取一个
|
||||||
|
32 | 8: 1,
|
||||||
|
64 | 8: 2,
|
||||||
|
4 | 8: 3,
|
||||||
|
32 | 64: 1,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := normalizeBookType(in); got != want {
|
||||||
|
t.Errorf("normalizeBookType(%d) = %d,期望 %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:书架未读章数所需的「网络书籍总章数从目录缓存补齐」逻辑回归测试。
|
||||||
|
// 前端的未读章数 = total_chapter_num -(已读到第几章),所以这里保证 total_chapter_num 可靠。
|
||||||
|
|
||||||
|
func TestListBooksFillsChapterCountFromCache(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||||
|
Origin: "https://example.com",
|
||||||
|
OriginName: "测试书源",
|
||||||
|
BookURL: "https://example.com/book/1",
|
||||||
|
}, "测试书", "作者", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加入书架失败: %v", err)
|
||||||
|
}
|
||||||
|
if book.TotalChapterNum != 0 {
|
||||||
|
t.Fatalf("刚加入书架的网文总章数 = %d,期望 0", book.TotalChapterNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目录还没缓存:书架读不到总章数,前端此时不显示未读徽标
|
||||||
|
books, err := svc.ListBooks(ctx, "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(books) != 1 || books[0].TotalChapterNum != 0 {
|
||||||
|
t.Fatalf("未缓存目录时书架 = %+v,期望 1 本且总章数 0", books)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 5 章后,书架应能算出总章数
|
||||||
|
chapters := make([]ChapterInput, 0, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
chapters = append(chapters, ChapterInput{
|
||||||
|
Index: i,
|
||||||
|
Title: fmt.Sprintf("第 %d 章", i+1),
|
||||||
|
URL: fmt.Sprintf("https://example.com/c/%d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||||
|
t.Fatalf("保存目录失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
books, err = svc.ListBooks(ctx, "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if books[0].TotalChapterNum != 5 {
|
||||||
|
t.Fatalf("缓存 5 章后总章数 = %d,期望 5", books[0].TotalChapterNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读到第 2 章后,前端用 dur_chapter_index + 1 算已读;这里确认进度按原样回读
|
||||||
|
if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第 2 章"); err != nil {
|
||||||
|
t.Fatalf("保存进度失败: %v", err)
|
||||||
|
}
|
||||||
|
books, err = svc.ListBooks(ctx, "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if books[0].DurChapterIndex != 1 || books[0].DurChapterTime == 0 {
|
||||||
|
t.Fatalf("进度 = index %d / time %d,期望 index 1 且有时间戳", books[0].DurChapterIndex, books[0].DurChapterTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBooksChapterCountIsPerUser(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||||
|
Origin: "https://example.com",
|
||||||
|
BookURL: "https://example.com/book/1",
|
||||||
|
}, "测试书", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: "u"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 别人的书架不应该受影响,也不该拿到别人的书
|
||||||
|
other, err := svc.ListBooks(ctx, "u2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(other) != 0 {
|
||||||
|
t.Fatalf("u2 的书架应为空,得到 %+v", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmUpBookChaptersSkipsWhenCacheExists(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||||
|
Origin: "https://example.com",
|
||||||
|
BookURL: "https://example.com/book/1",
|
||||||
|
}, "测试书", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
chapters := []ChapterInput{
|
||||||
|
{Index: 0, Title: "第 1 章", URL: "https://example.com/c/0"},
|
||||||
|
{Index: 1, Title: "第 2 章", URL: "https://example.com/c/1"},
|
||||||
|
}
|
||||||
|
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有目录缓存时直接返回,不联网、不覆盖缓存
|
||||||
|
svc.WarmUpBookChapters(ctx, "u1", book)
|
||||||
|
|
||||||
|
got, err := svc.ListChapters(ctx, book.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Title != "第 1 章" {
|
||||||
|
t.Fatalf("缓存被改动: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmUpBookChaptersSkipsLocalBook(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("导入失败: %v", err)
|
||||||
|
}
|
||||||
|
// 本地书籍不应触发联网抓目录(书源为空的地址必然失败),章节保持导入时的结果
|
||||||
|
svc.WarmUpBookChapters(ctx, "u1", book)
|
||||||
|
|
||||||
|
got, err := svc.ListChapters(ctx, book.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("本地书籍章节数 = %d,期望 3", len(got))
|
||||||
|
}
|
||||||
|
if book.TotalChapterNum != 3 {
|
||||||
|
t.Fatalf("本地书籍总章数 = %d,期望 3", book.TotalChapterNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:书源 JS 的宿主浏览器(java.startBrowser / java.startBrowserAwait)。
|
||||||
|
//
|
||||||
|
// legado 用内置 WebView 承载页面:书源在自己的 JS 里拼一段 HTML(线路设置页
|
||||||
|
// 之类)或给一个网址,让用户点选、登录、过防爬校验,然后(Await 版本)把用户
|
||||||
|
// 操作后的页面源码当成 StrResponse 返回,书源再从中解析出结果写回源变量。
|
||||||
|
//
|
||||||
|
// 服务端没有 WebView,于是把这一段拆成三步:
|
||||||
|
// 1. JS 侧 startBrowserAwait 登记一个待办并阻塞等待;
|
||||||
|
// 2. 前端轮询到待办,在 <iframe> 里承载页面(同源,因此可以直接读回 DOM);
|
||||||
|
// 3. 用户点 √ 后把 iframe 的 DOM 回传,阻塞解除,JS 拿到 body()。
|
||||||
|
//
|
||||||
|
// 页面来源分两种,承载方式不同:
|
||||||
|
// - data:text/html;base64,…:书源自带的内嵌页面,直接解码返回;
|
||||||
|
// - http(s):服务端带书源 Cookie/请求头抓取后返回。这一步很关键——书源的
|
||||||
|
// 登录态存在服务端,浏览器里没有这些 Cookie,若直接让浏览器打开源站,
|
||||||
|
// 「用户后台」这类页面必然是未登录状态。页面里的资源地址会改写到同源
|
||||||
|
// 代理,使后续请求同样带上 Cookie。
|
||||||
|
|
||||||
|
const (
|
||||||
|
// browserAwaitTimeout 单次等待用户操作的上限。
|
||||||
|
browserAwaitTimeout = 10 * time.Minute
|
||||||
|
// browserPageTTL 待办条目与页面链接的有效期。
|
||||||
|
browserPageTTL = 15 * time.Minute
|
||||||
|
// browserMaxPageBytes 服务端抓取页面/资源的大小上限。
|
||||||
|
browserMaxPageBytes = 4 << 20
|
||||||
|
// browserFetchTimeout 抓取待承载页面/资源的超时。
|
||||||
|
browserFetchTimeout = 20 * time.Second
|
||||||
|
// browserModeWait 需要回传 DOM(startBrowserAwait)。
|
||||||
|
browserModeWait = "wait"
|
||||||
|
// browserModeOpen 只展示(startBrowser / showBrowser)。
|
||||||
|
browserModeOpen = "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BrowserPage 前端待承载的一个页面(登录对话框轮询用)。
|
||||||
|
type BrowserPage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
||||||
|
PageURL string `json:"page_url"`
|
||||||
|
// Refetch 见 rule.BrowserTask.Refetch。
|
||||||
|
Refetch bool `json:"refetch"`
|
||||||
|
// SourceID 发起该请求的书源。
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
// TargetURL 原始地址(data: 与超长地址不回传)。
|
||||||
|
TargetURL string `json:"target_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowserPageSnapshot 承载页面时返回的数据。
|
||||||
|
type BrowserPageSnapshot struct {
|
||||||
|
ID string
|
||||||
|
HTML string
|
||||||
|
SourceID string
|
||||||
|
FinalURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingBrowser 一个待用户完成的页面。
|
||||||
|
type pendingBrowser struct {
|
||||||
|
id string
|
||||||
|
sourceID string
|
||||||
|
sourceURL string
|
||||||
|
userID string
|
||||||
|
request rule.BrowserTask
|
||||||
|
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||||
|
html string
|
||||||
|
finalURL string
|
||||||
|
mode string
|
||||||
|
expires time.Time
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
result rule.BrowserResult
|
||||||
|
err error
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserHost 把服务层的浏览器面板绑定到某个书源会话(实现 rule.BrowserHost)。
|
||||||
|
type browserHost struct {
|
||||||
|
svc *ReaderService
|
||||||
|
sourceURL string
|
||||||
|
sourceID string
|
||||||
|
userID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||||
|
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||||
|
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||||
|
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||||
|
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||||
|
if err != nil {
|
||||||
|
return rule.BrowserResult{}, err
|
||||||
|
}
|
||||||
|
defer s.dropBrowser(entry.id)
|
||||||
|
|
||||||
|
timeout := time.NewTimer(browserAwaitTimeout)
|
||||||
|
defer timeout.Stop()
|
||||||
|
select {
|
||||||
|
case <-entry.done:
|
||||||
|
entry.mu.Lock()
|
||||||
|
defer entry.mu.Unlock()
|
||||||
|
if entry.err != nil {
|
||||||
|
return rule.BrowserResult{}, entry.err
|
||||||
|
}
|
||||||
|
return entry.result, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return rule.BrowserResult{}, ctx.Err()
|
||||||
|
case <-timeout.C:
|
||||||
|
return rule.BrowserResult{}, fmt.Errorf("等待页面操作超时(%s),请重试", browserAwaitTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||||
|
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||||
|
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBrowser 准备页面内容并登记待办。
|
||||||
|
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||||
|
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||||
|
id := newBrowserID()
|
||||||
|
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if finalURL == "" {
|
||||||
|
finalURL = req.URL
|
||||||
|
}
|
||||||
|
entry := &pendingBrowser{
|
||||||
|
id: id,
|
||||||
|
sourceID: sourceID,
|
||||||
|
sourceURL: sourceURL,
|
||||||
|
userID: userID,
|
||||||
|
request: req,
|
||||||
|
html: html,
|
||||||
|
finalURL: finalURL,
|
||||||
|
mode: mode,
|
||||||
|
expires: time.Now().Add(browserPageTTL),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.browserMu.Lock()
|
||||||
|
if s.browserPending == nil {
|
||||||
|
s.browserPending = map[string]*pendingBrowser{}
|
||||||
|
}
|
||||||
|
s.pruneBrowsersLocked()
|
||||||
|
s.browserPending[id] = entry
|
||||||
|
s.browserMu.Unlock()
|
||||||
|
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Info("reader:待用户完成页面操作",
|
||||||
|
zap.String("source", sourceID), zap.String("mode", mode), zap.String("title", req.Title))
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserBridgeScript 注入到承载页面里:父窗口请求时回传当前 DOM,把页面自己的
|
||||||
|
// fetch / XMLHttpRequest 转交给父窗口代为请求,并补上沙箱里不可用的存储对象。
|
||||||
|
//
|
||||||
|
// 为什么不让父窗口直接读 contentDocument:那要求 iframe 同源,而书源页面是
|
||||||
|
// 第三方 HTML,拿到同源权限就能读写 MeBox 自己的页面与 localStorage(JWT)。
|
||||||
|
// 改成 postMessage 后,iframe 可以只开 allow-scripts(不透明源)。
|
||||||
|
//
|
||||||
|
// 为什么要补存储对象:不透明源里访问 localStorage / sessionStorage / document.cookie
|
||||||
|
// 会直接抛 SecurityError,SPA 类的「用户后台」在启动读 token 时就崩了,
|
||||||
|
// 表现是一直停在「正在安全加载」。这里用内存版兜住,既不给出同源权限,
|
||||||
|
// 又让页面能正常跑完启动流程。
|
||||||
|
//
|
||||||
|
// 为什么要代发请求:iframe 是不透明源,页面自己的 XHR 既带不上书源 Cookie,
|
||||||
|
// 也会被 CORS 拦掉——靠接口取数的页面会取不到数据。
|
||||||
|
const browserBridgeScript = `<script data-mebox-browser-bridge="1">(function(){` +
|
||||||
|
`if(window.__meboxBridgeInstalled){return}window.__meboxBridgeInstalled=true;` +
|
||||||
|
// ── 沙箱内存储兜底 ──
|
||||||
|
`function memStore(){var d={};return{` +
|
||||||
|
`getItem:function(k){k=String(k);return Object.prototype.hasOwnProperty.call(d,k)?d[k]:null},` +
|
||||||
|
`setItem:function(k,v){d[String(k)]=String(v)},` +
|
||||||
|
`removeItem:function(k){delete d[String(k)]},` +
|
||||||
|
`clear:function(){d={}},` +
|
||||||
|
`key:function(i){var ks=Object.keys(d);return i>=0&&i<ks.length?ks[i]:null},` +
|
||||||
|
`get length(){return Object.keys(d).length}}}` +
|
||||||
|
`function patchStore(n){try{var s=memStore();Object.defineProperty(window,n,{configurable:true,get:function(){return s}})}catch(e){}}` +
|
||||||
|
`patchStore('localStorage');patchStore('sessionStorage');` +
|
||||||
|
// document.cookie 也要给:这类页面常用它判断是否已登录(真实 Cookie 在服务端,
|
||||||
|
// 浏览器里没有)。把书源在该站点的 Cookie 预置进去,页面才会正常渲染。
|
||||||
|
`var ck={};` +
|
||||||
|
`(function(seed){String(seed||'').split(';').forEach(function(p){var i=p.indexOf('=');if(i>0){ck[p.slice(0,i).trim()]=p.slice(i+1).trim()}})})` + "(`__MEBOX_COOKIE__`)" + `;` +
|
||||||
|
`try{Object.defineProperty(document,'cookie',{configurable:true,` +
|
||||||
|
`get:function(){var o=[];for(var k in ck){o.push(k+'='+ck[k])}return o.join('; ')},` +
|
||||||
|
`set:function(v){try{var p=String(v).split(';')[0].split('=');if(p[0]){ck[p[0].trim()]=p.slice(1).join('=')}}catch(e){}}})}catch(e){}` +
|
||||||
|
// 书源页面自身的基地址:相对地址要按它解析,而不是按承载地址(我们自己的源)
|
||||||
|
`var BASE=` + "`__MEBOX_BASE_URL__`" + `;` +
|
||||||
|
`function abs(u){try{return new URL(u,BASE||location.href).href}catch(e){try{return new URL(u,location.href).href}catch(e2){return u}}}` +
|
||||||
|
// ── DOM 回传 ──
|
||||||
|
`window.addEventListener('message',function(e){` +
|
||||||
|
`if(!e||e.data!=='__mebox_dom__'){return}` +
|
||||||
|
`try{parent.postMessage({__mebox_dom__:true,html:document.documentElement.outerHTML},'*')}catch(err){}` +
|
||||||
|
`},false);` +
|
||||||
|
// ── 请求代理(父窗口持有登录态,由它转交服务端补 Cookie) ──
|
||||||
|
`var seq=0,waiters={};` +
|
||||||
|
`window.addEventListener('message',function(e){` +
|
||||||
|
`var d=e&&e.data;` +
|
||||||
|
`if(!d||d.__mebox_proxy_res__!==true){return}` +
|
||||||
|
`var w=waiters[d.reqId];` +
|
||||||
|
`if(!w){return}delete waiters[d.reqId];w(d);` +
|
||||||
|
`},false);` +
|
||||||
|
`function rpc(method,url,headers,body){` +
|
||||||
|
`return new Promise(function(resolve,reject){` +
|
||||||
|
`var id='r'+(++seq);waiters[id]=resolve;` +
|
||||||
|
`try{parent.postMessage({__mebox_proxy__:true,reqId:id,url:url,method:method,headers:headers||{},body:body||''},'*')}catch(e){delete waiters[id];reject(e);return}` +
|
||||||
|
`setTimeout(function(){if(waiters[id]){delete waiters[id];reject(new Error('mebox proxy timeout'))}},60000);` +
|
||||||
|
`})}` +
|
||||||
|
`function decode(res){return res&&res.base64?atob(res.body||''):((res&&res.body)||'')}` +
|
||||||
|
`var origFetch=window.fetch;` +
|
||||||
|
`if(origFetch){window.fetch=function(input,init){` +
|
||||||
|
`init=init||{};` +
|
||||||
|
`var url=typeof input==='string'?input:(input&&input.url);` +
|
||||||
|
`if(!url){return origFetch.apply(this,arguments)}` +
|
||||||
|
`var method=String(init.method||(input&&input.method)||'GET').toUpperCase();` +
|
||||||
|
`var headers={};` +
|
||||||
|
`try{new Headers(init.headers||(input&&input.headers)||{}).forEach(function(v,k){headers[k]=v})}catch(e){}` +
|
||||||
|
`var body=init.body?String(init.body):'';` +
|
||||||
|
`return rpc(method,abs(url),headers,body).then(function(res){` +
|
||||||
|
`return new Response(decode(res),{status:(res&&res.status)||200,headers:{'Content-Type':(res&&res.contentType)||'text/plain'}})` +
|
||||||
|
`})}}` +
|
||||||
|
`var OrigXHR=window.XMLHttpRequest;` +
|
||||||
|
`function ProxyXHR(){this._h={};this._m='GET';this._u='';this.readyState=0;this.status=0;this.response=null;this.responseText='';this.responseType=''}` +
|
||||||
|
`ProxyXHR.prototype.open=function(m,u){this._m=m;this._u=u;this.readyState=1;this._fire('onreadystatechange')};` +
|
||||||
|
`ProxyXHR.prototype.setRequestHeader=function(k,v){this._h[k]=v};` +
|
||||||
|
`ProxyXHR.prototype._fire=function(n){try{if(this[n])this[n]()}catch(e){}};` +
|
||||||
|
`ProxyXHR.prototype.send=function(body){` +
|
||||||
|
`var self=this;` +
|
||||||
|
`rpc(String(this._m).toUpperCase(),abs(this._u),this._h,body?String(body):'').then(function(res){` +
|
||||||
|
`self.status=(res&&res.status)||200;self.readyState=4;self.responseText=decode(res);` +
|
||||||
|
`if(self.responseType==='json'){try{self.response=JSON.parse(self.responseText)}catch(e){self.response=null}}else{self.response=self.responseText}` +
|
||||||
|
`self._fire('onreadystatechange');self._fire('onload')` +
|
||||||
|
`},function(){self.status=0;self.readyState=4;self._fire('onreadystatechange');self._fire('onerror')})};` +
|
||||||
|
`ProxyXHR.prototype.getResponseHeader=function(){return null};` +
|
||||||
|
`ProxyXHR.prototype.getAllResponseHeaders=function(){return ''};` +
|
||||||
|
`ProxyXHR.prototype.abort=function(){};` +
|
||||||
|
`window.XMLHttpRequest=ProxyXHR;` +
|
||||||
|
`})();</script>`
|
||||||
|
|
||||||
|
// injectBrowserBridge 把回传/代理脚本插进页面 <head>(没有 head 就插在最前面),
|
||||||
|
// 并把书源页面的基地址与 Cookie 填进脚本:前者供相对地址解析,后者供页面判断
|
||||||
|
// 登录态(真实 Cookie 在服务端,页面看不到)。
|
||||||
|
func injectBrowserBridge(html, baseURL, cookie string) string {
|
||||||
|
if strings.Contains(html, `data-mebox-browser-bridge`) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
script := strings.ReplaceAll(browserBridgeScript, "__MEBOX_BASE_URL__", jsStringEscape(baseURL))
|
||||||
|
script = strings.ReplaceAll(script, "__MEBOX_COOKIE__", jsStringEscape(cookie))
|
||||||
|
lower := strings.ToLower(html)
|
||||||
|
if i := strings.Index(lower, "<head>"); i >= 0 {
|
||||||
|
pos := i + len("<head>")
|
||||||
|
return html[:pos] + script + html[pos:]
|
||||||
|
}
|
||||||
|
if i := strings.Index(lower, "<html"); i >= 0 {
|
||||||
|
if j := strings.Index(lower[i:], ">"); j >= 0 {
|
||||||
|
pos := i + j + 1
|
||||||
|
return html[:pos] + script + html[pos:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return script + html
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsStringEscape 转义要嵌进 JS 模板字符串的地址(反引号 / 反斜杠 / ${)。
|
||||||
|
func jsStringEscape(s string) string {
|
||||||
|
r := strings.NewReplacer("\\", "\\\\", "`", "\\`", "${", "\\${")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareBrowserPage 得到 iframe 要承载的页面源码。
|
||||||
|
//
|
||||||
|
// 返回的页面里会注入回传/代理脚本;脚本需要两样东西:
|
||||||
|
// - 基地址:解析页面里的相对地址(书源自带的 data: 页面没有真实地址,
|
||||||
|
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||||
|
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||||
|
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||||
|
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||||
|
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||||
|
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||||
|
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||||
|
}
|
||||||
|
if html := rule.ParseDataHTML(req.URL); html != "" {
|
||||||
|
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||||
|
}
|
||||||
|
// 2) 外部地址:服务端带书源 Cookie 抓取,并把资源地址改写到代理
|
||||||
|
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||||
|
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||||
|
}
|
||||||
|
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||||
|
}
|
||||||
|
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
|
||||||
|
if !strings.Contains(strings.ToLower(contentType), "html") {
|
||||||
|
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
|
||||||
|
wrapped := "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>" + htmlEscape(req.Title) +
|
||||||
|
"</title></head><body><pre style=\"white-space:pre-wrap;word-break:break-all;padding:16px;" +
|
||||||
|
"font:13px/1.6 ui-monospace,monospace\">" + htmlEscape(body) + "</pre></body></html>"
|
||||||
|
return injectBrowserBridge(wrapped, finalURL, cookie), finalURL, nil
|
||||||
|
}
|
||||||
|
return injectBrowserBridge(s.rewriteBrowserHTML(body, finalURL, id), finalURL, cookie), finalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserCookieHeader 取书源在目标站点上的 Cookie 串(页面里预置 document.cookie 用)。
|
||||||
|
func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, target string) string {
|
||||||
|
if s.repo == nil || sourceURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.newSourceState(ctx, sourceURL).CookieForRequest(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
|
||||||
|
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
|
||||||
|
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
return string(data), finalURL, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
|
||||||
|
// 返回 (contentType, status, body, error)。
|
||||||
|
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
|
||||||
|
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||||
|
return contentType, status, data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestBrowserResource 带书源凭据请求一个外部地址。
|
||||||
|
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
state := s.newSourceState(reqCtx, sourceURL)
|
||||||
|
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, "", err
|
||||||
|
}
|
||||||
|
for k, v := range state.LoginHeaderMap() {
|
||||||
|
if strings.EqualFold(k, "cookie") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if ck := state.CookieForRequest(target); ck != "" {
|
||||||
|
httpReq.Header.Set("Cookie", ck)
|
||||||
|
}
|
||||||
|
// 书源级请求头(含 Referer / UA),与阅读请求保持一致
|
||||||
|
if s.repo != nil {
|
||||||
|
if src, findErr := s.repo.GetSourceByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||||
|
var headers map[string]any
|
||||||
|
if json.Unmarshal([]byte(src.Header), &headers) == nil {
|
||||||
|
for k, v := range headers {
|
||||||
|
if httpReq.Header.Get(k) == "" {
|
||||||
|
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Accept-Encoding 交给 net/http 管:显式设置会让它放弃自动解压,
|
||||||
|
// 压缩过的页面/资源就会以原始字节回到 iframe(页面直接白屏或乱码)。
|
||||||
|
helper.StripAcceptEncoding(httpReq.Header)
|
||||||
|
resp, err := s.http.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, browserMaxPageBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, "", err
|
||||||
|
}
|
||||||
|
data = helper.DecompressBody(resp, data)
|
||||||
|
finalURL := target
|
||||||
|
if resp.Request != nil && resp.Request.URL != nil {
|
||||||
|
finalURL = resp.Request.URL.String()
|
||||||
|
}
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
return contentType, resp.StatusCode, data, finalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowserXHRResult 页面内 fetch/XHR 经服务端转发后的响应。
|
||||||
|
type BrowserXHRResult struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// Base64 为真时 Body 是 base64(二进制资源)。
|
||||||
|
Base64 bool `json:"base64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserXHRMaxBytes 转发接口响应的大小上限。
|
||||||
|
const browserXHRMaxBytes = 4 << 20
|
||||||
|
|
||||||
|
// ProxyBrowserXHR 以书源身份转发页面内的接口请求。
|
||||||
|
//
|
||||||
|
// 承载页面是不透明源,页面自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦掉,
|
||||||
|
// 所以由父窗口把请求转交进来,这里补上书源凭据再发出去。
|
||||||
|
// 返回的 HTTP 状态/内容类型原样回给页面,让页面自己的逻辑能正常分支。
|
||||||
|
func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target string, headers map[string]string, body string) (*BrowserXHRResult, error) {
|
||||||
|
entry := s.lookupBrowser(id)
|
||||||
|
if entry == nil {
|
||||||
|
return nil, errors.New("页面已过期,请重新打开")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
|
||||||
|
return nil, fmt.Errorf("仅支持 http(s) 地址: %s", truncateForLog(target, 120))
|
||||||
|
}
|
||||||
|
if method == "" {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 先放页面自己声明的头,再用书源凭据补缺(Cookie 始终以服务端为准)
|
||||||
|
for k, v := range headers {
|
||||||
|
if strings.EqualFold(k, "cookie") || strings.EqualFold(k, "host") ||
|
||||||
|
strings.EqualFold(k, "content-length") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
state := s.newSourceState(reqCtx, entry.sourceURL)
|
||||||
|
for k, v := range state.LoginHeaderMap() {
|
||||||
|
if strings.EqualFold(k, "cookie") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if httpReq.Header.Get(k) == "" {
|
||||||
|
httpReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if httpReq.Header.Get("Cookie") == "" {
|
||||||
|
if ck := state.CookieForRequest(target); ck != "" {
|
||||||
|
httpReq.Header.Set("Cookie", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.repo != nil {
|
||||||
|
if src, findErr := s.repo.GetSourceByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||||
|
var extra map[string]any
|
||||||
|
if json.Unmarshal([]byte(src.Header), &extra) == nil {
|
||||||
|
for k, v := range extra {
|
||||||
|
if httpReq.Header.Get(k) == "" {
|
||||||
|
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.StripAcceptEncoding(httpReq.Header)
|
||||||
|
resp, err := s.http.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, browserXHRMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = helper.DecompressBody(resp, data)
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
|
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
|
||||||
|
if isTextualContent(contentType) {
|
||||||
|
out.Body = string(data)
|
||||||
|
} else {
|
||||||
|
out.Body = base64.StdEncoding.EncodeToString(data)
|
||||||
|
out.Base64 = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTextualContent 判断响应是否可以直接当字符串交给页面。
|
||||||
|
func isTextualContent(contentType string) bool {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
for _, p := range []string{"text/", "json", "javascript", "xml", "html", "x-www-form-urlencoded", "csv"} {
|
||||||
|
if strings.Contains(ct, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 资源改写与代理 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// cssURLRe 匹配 CSS 里的 url(...) 引用。
|
||||||
|
// Go 的 regexp 不支持反向引用,因此引号用可选的成对字符类近似匹配。
|
||||||
|
var cssURLRe = regexp.MustCompile(`url\(\s*['"]?([^'")]+)['"]?\s*\)`)
|
||||||
|
|
||||||
|
// rewriteBrowserHTML 把页面里的资源/表单/站内链接地址改写到同源代理,
|
||||||
|
// 使页面在 iframe 里的后续请求同样带上书源 Cookie。
|
||||||
|
func (s *ReaderService) rewriteBrowserHTML(body, baseURL, id string) string {
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
rewrite := func(sel, attr string) {
|
||||||
|
doc.Find(sel).Each(func(_ int, node *goquery.Selection) {
|
||||||
|
val, ok := node.Attr(attr)
|
||||||
|
if !ok || strings.TrimSpace(val) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abs := absoluteBrowserURL(base, val); abs != "" {
|
||||||
|
node.SetAttr(attr, s.browserAssetProxyURL(id, abs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rewrite("img[src]", "src")
|
||||||
|
rewrite("script[src]", "src")
|
||||||
|
rewrite("link[href]", "href")
|
||||||
|
rewrite("iframe[src]", "src")
|
||||||
|
rewrite("source[src]", "src")
|
||||||
|
rewrite("video[src]", "src")
|
||||||
|
rewrite("audio[src]", "src")
|
||||||
|
// 表单提交也走代理,避免 POST 丢掉 Cookie
|
||||||
|
rewrite("form[action]", "action")
|
||||||
|
// 站内链接走代理;站外链接保留(用户可能确实想出去)
|
||||||
|
doc.Find("a[href]").Each(func(_ int, node *goquery.Selection) {
|
||||||
|
val, ok := node.Attr("href")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs := absoluteBrowserURL(base, val)
|
||||||
|
if abs == "" || !sameSite(abs, baseURL) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.SetAttr("href", s.browserAssetProxyURL(id, abs))
|
||||||
|
})
|
||||||
|
// 内联样式里的 url(...)
|
||||||
|
doc.Find("[style]").Each(func(_ int, node *goquery.Selection) {
|
||||||
|
style, ok := node.Attr("style")
|
||||||
|
if !ok || !strings.Contains(style, "url(") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.SetAttr("style", rewriteCSSURLs(style, base, func(abs string) string {
|
||||||
|
return s.browserAssetProxyURL(id, abs)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
out, err := doc.Html()
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return "<!DOCTYPE html>" + out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteBrowserCSS 改写 CSS 文本里的 url(...) 引用(资源代理用)。
|
||||||
|
func (s *ReaderService) RewriteBrowserCSS(css, baseURL, id string) string {
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return css
|
||||||
|
}
|
||||||
|
return rewriteCSSURLs(css, base, func(abs string) string {
|
||||||
|
return s.browserAssetProxyURL(id, abs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteCSSURLs(css string, base *url.URL, proxy func(abs string) string) string {
|
||||||
|
return cssURLRe.ReplaceAllStringFunc(css, func(m string) string {
|
||||||
|
sub := cssURLRe.FindStringSubmatch(m)
|
||||||
|
if len(sub) < 2 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
abs := absoluteBrowserURL(base, sub[1])
|
||||||
|
if abs == "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return `url("` + proxy(abs) + `")`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// absoluteBrowserURL 把页面里的相对地址解析成绝对地址;不可代理的协议返回空串。
|
||||||
|
func absoluteBrowserURL(base *url.URL, ref string) string {
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
if ref == "" || strings.HasPrefix(ref, "#") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(ref)
|
||||||
|
for _, p := range []string{"data:", "javascript:", "mailto:", "tel:", "blob:", "about:", "ws:", "wss:"} {
|
||||||
|
if strings.HasPrefix(lower, p) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
abs := base.ResolveReference(parsed).String()
|
||||||
|
if !strings.HasPrefix(abs, "http://") && !strings.HasPrefix(abs, "https://") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameSite 判断两个地址是否同站(eTLD+1)。
|
||||||
|
func sameSite(a, b string) bool {
|
||||||
|
return rule.CookieDomain(a) == rule.CookieDomain(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 签名与地址 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// browserSign 用 JWT 密钥做 HMAC,绑定待办 ID(页面)或 ID+目标地址(资源)。
|
||||||
|
func (s *ReaderService) browserSign(payload string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReaderService) browserPageSig(id string) string { return s.browserSign("page|" + id) }
|
||||||
|
|
||||||
|
func (s *ReaderService) browserAssetSig(id, target string) string {
|
||||||
|
return s.browserSign("asset|" + id + "|" + target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReaderService) browserPageURL(id string) string {
|
||||||
|
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
|
||||||
|
func (s *ReaderService) browserAssetProxyURL(id, target string) string {
|
||||||
|
return "/api/reader/browser/asset?id=" + url.QueryEscape(id) +
|
||||||
|
"&s=" + s.browserAssetSig(id, target) +
|
||||||
|
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBrowserPage 校验页面承载签名,返回待办快照。
|
||||||
|
func (s *ReaderService) VerifyBrowserPage(id, sig string) (*BrowserPageSnapshot, error) {
|
||||||
|
if !hmac.Equal([]byte(s.browserPageSig(id)), []byte(sig)) {
|
||||||
|
return nil, errors.New("页面地址签名校验失败")
|
||||||
|
}
|
||||||
|
entry := s.lookupBrowser(id)
|
||||||
|
if entry == nil {
|
||||||
|
return nil, errors.New("页面已过期,请重新打开")
|
||||||
|
}
|
||||||
|
return &BrowserPageSnapshot{
|
||||||
|
ID: entry.id,
|
||||||
|
HTML: entry.html,
|
||||||
|
SourceID: entry.sourceID,
|
||||||
|
FinalURL: entry.finalURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBrowserAsset 校验资源代理签名并还原目标地址,返回(目标地址, 书源 URL)。
|
||||||
|
func (s *ReaderService) VerifyBrowserAsset(id, encoded, sig string) (string, string, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.New("资源地址解码失败")
|
||||||
|
}
|
||||||
|
target := string(raw)
|
||||||
|
if !hmac.Equal([]byte(s.browserAssetSig(id, target)), []byte(sig)) {
|
||||||
|
return "", "", errors.New("资源地址签名校验失败")
|
||||||
|
}
|
||||||
|
entry := s.lookupBrowser(id)
|
||||||
|
if entry == nil {
|
||||||
|
return "", "", errors.New("页面已过期,请重新打开")
|
||||||
|
}
|
||||||
|
return target, entry.sourceURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 待办表 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// PendingBrowserPages 返回该用户在某书源下待用户完成的页面(前端轮询)。
|
||||||
|
// 含 open 模式:startBrowser 只展示不回传,同样需要前端把页面呈现出来。
|
||||||
|
func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPage {
|
||||||
|
s.browserMu.Lock()
|
||||||
|
defer s.browserMu.Unlock()
|
||||||
|
s.pruneBrowsersLocked()
|
||||||
|
out := make([]BrowserPage, 0, len(s.browserPending))
|
||||||
|
for _, e := range s.browserPending {
|
||||||
|
if e.sourceID != sourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
|
||||||
|
if e.userID != "" && userID != "" && e.userID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s.pageOfLocked(e))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
|
||||||
|
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
|
||||||
|
entry := s.lookupBrowser(id)
|
||||||
|
if entry == nil {
|
||||||
|
return errors.New("页面已过期或已完成")
|
||||||
|
}
|
||||||
|
entry.mu.Lock()
|
||||||
|
defer entry.mu.Unlock()
|
||||||
|
if entry.finished {
|
||||||
|
return errors.New("该页面已完成")
|
||||||
|
}
|
||||||
|
if cancelled {
|
||||||
|
entry.err = rule.ErrBrowserCancelled
|
||||||
|
} else {
|
||||||
|
resultURL := strings.TrimSpace(finalURL)
|
||||||
|
if resultURL == "" {
|
||||||
|
resultURL = entry.finalURL
|
||||||
|
}
|
||||||
|
entry.result = rule.BrowserResult{URL: resultURL, Body: body}
|
||||||
|
}
|
||||||
|
entry.finished = true
|
||||||
|
if entry.done != nil {
|
||||||
|
close(entry.done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReaderService) lookupBrowser(id string) *pendingBrowser {
|
||||||
|
s.browserMu.Lock()
|
||||||
|
defer s.browserMu.Unlock()
|
||||||
|
s.pruneBrowsersLocked()
|
||||||
|
return s.browserPending[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReaderService) dropBrowser(id string) {
|
||||||
|
s.browserMu.Lock()
|
||||||
|
defer s.browserMu.Unlock()
|
||||||
|
delete(s.browserPending, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneBrowsersLocked 清掉过期条目并唤醒仍在等待的调用方(避免泄漏协程)。
|
||||||
|
func (s *ReaderService) pruneBrowsersLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for id, e := range s.browserPending {
|
||||||
|
if now.Before(e.expires) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
if !e.finished {
|
||||||
|
e.finished = true
|
||||||
|
e.err = errors.New("页面等待超时")
|
||||||
|
if e.done != nil {
|
||||||
|
close(e.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
delete(s.browserPending, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
|
||||||
|
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||||
|
target := e.request.URL
|
||||||
|
if strings.HasPrefix(target, "data:") || len(target) > 512 {
|
||||||
|
target = ""
|
||||||
|
}
|
||||||
|
return BrowserPage{
|
||||||
|
ID: e.id,
|
||||||
|
Title: e.request.Title,
|
||||||
|
Mode: e.mode,
|
||||||
|
PageURL: s.browserPageURL(e.id),
|
||||||
|
Refetch: e.request.Refetch,
|
||||||
|
SourceID: e.sourceID,
|
||||||
|
TargetURL: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 小工具 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func newBrowserID() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForLog(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:书源宿主浏览器面板的服务层测试(browser_panel.go)。
|
||||||
|
//
|
||||||
|
// 覆盖真实书源依赖的三件事:
|
||||||
|
// 1. startBrowserAwait 登记待办 → 前端轮询到 → 回传 DOM → 阻塞解除;
|
||||||
|
// 2. http(s) 页面由服务端带书源 Cookie 抓取(浏览器里没有这些 Cookie);
|
||||||
|
// 3. 页面里的资源地址被改写到同源代理,且签名校验拦得住伪造。
|
||||||
|
|
||||||
|
// browserPanelSourceJSON 构造一个内联书源:登录面板有「切换线路」按钮,
|
||||||
|
// 其实现与光遇聚合的 getServerSettings 同构——打开内嵌 HTML,回传 DOM 后
|
||||||
|
// 从 DOM 里解析线路写进源变量。
|
||||||
|
func browserPanelSourceJSON(t *testing.T, sourceURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
loginJS := `function switchLine() {
|
||||||
|
let html = '<!DOCTYPE html><html><body><span id="serverValue">线路甲</span></body></html>';
|
||||||
|
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
|
||||||
|
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
|
||||||
|
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}`
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": sourceURL,
|
||||||
|
"bookSourceName": "浏览器面板测试源",
|
||||||
|
"loginUrl": loginJS,
|
||||||
|
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// querySig 从签名地址里取出 s 参数。
|
||||||
|
func querySig(t *testing.T, raw string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("承载地址不合法: %q", raw)
|
||||||
|
}
|
||||||
|
return u.Query().Get("s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitPending 轮询等待出现一个待办页面。
|
||||||
|
func waitPending(t *testing.T, svc *ReaderService, sourceID string) BrowserPage {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||||
|
if len(pages) > 0 {
|
||||||
|
return pages[0]
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("等待超时:未出现待用户完成的页面")
|
||||||
|
return BrowserPage{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelSwitchLineRoundTrip 端到端:书源阻塞 → 前端拿到页面 →
|
||||||
|
// 回传 DOM → 书源把线路写进源变量。
|
||||||
|
func TestBrowserPanelSwitchLineRoundTrip(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||||
|
|
||||||
|
type actionResult struct {
|
||||||
|
res *LoginResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan actionResult, 1)
|
||||||
|
go func() {
|
||||||
|
res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "switchLine()", nil)
|
||||||
|
done <- actionResult{res: res, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
page := waitPending(t, svc, sourceID)
|
||||||
|
if page.Mode != browserModeWait {
|
||||||
|
t.Fatalf("模式 = %q,期望 wait", page.Mode)
|
||||||
|
}
|
||||||
|
if page.Title != "线路设置" {
|
||||||
|
t.Fatalf("标题 = %q", page.Title)
|
||||||
|
}
|
||||||
|
// data: 地址不应回传给前端展示(又长又没用)
|
||||||
|
if page.TargetURL != "" {
|
||||||
|
t.Fatalf("data: 地址不应回传: %q", page.TargetURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 承载页面的签名必须有效,且内容是书源拼的那段 HTML
|
||||||
|
snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("承载页面校验失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(snap.HTML, `id="serverValue"`) {
|
||||||
|
t.Fatalf("承载页面内容异常: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
// 伪造签名必须被拒
|
||||||
|
if _, err := svc.VerifyBrowserPage(page.ID, "deadbeef"); err == nil {
|
||||||
|
t.Fatal("伪造签名不应通过校验")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟用户在页面里改选了「线路乙」后点 √:回传操作后的 DOM
|
||||||
|
if err := svc.ResolveBrowser(page.ID, readerTestUserID,
|
||||||
|
`<html><body><span id="serverValue">线路乙</span></body></html>`, "", false); err != nil {
|
||||||
|
t.Fatalf("回传结果失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
if r.err != nil {
|
||||||
|
t.Fatalf("登录动作失败: %v", r.err)
|
||||||
|
}
|
||||||
|
if !r.res.OK {
|
||||||
|
t.Fatalf("动作未成功: %+v", r.res)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("回传后阻塞未解除")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 书源应已把线路写进源变量(真正切换生效)
|
||||||
|
st := svc.newSourceState(ctx, "https://panel.example.com")
|
||||||
|
if !strings.Contains(st.GetVariable(), "线路乙") {
|
||||||
|
t.Fatalf("线路未写入源变量: %q", st.GetVariable())
|
||||||
|
}
|
||||||
|
// 待办应被清理
|
||||||
|
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
|
||||||
|
t.Fatalf("完成后待办未清理: %+v", pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelCancelReleasesBlock 取消页面时要解除阻塞(书源走降级分支)。
|
||||||
|
func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// action 为空表示执行 login();这里用 switchLine,取消后书源会抛异常
|
||||||
|
_, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
page := waitPending(t, svc, sourceID)
|
||||||
|
if err := svc.ResolveBrowser(page.ID, readerTestUserID, "", "", true); err != nil {
|
||||||
|
t.Fatalf("取消失败: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
// 书源未 catch,异常应回到调用方;关键是「不能一直卡住」
|
||||||
|
if err == nil {
|
||||||
|
t.Log("取消后动作用空 body 继续执行(书源自行降级)")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("取消后阻塞未解除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
|
||||||
|
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
|
||||||
|
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||||
|
var userCookie string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/user":
|
||||||
|
userCookie = r.Header.Get("Cookie")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if !strings.Contains(userCookie, "qttoken=") {
|
||||||
|
_, _ = w.Write([]byte(`<html><body>请先登录</body></html>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`<html><body><h1>我的账号</h1>` +
|
||||||
|
`<img src="/avatar.png"><a href="/orders">订单</a>` +
|
||||||
|
`<a href="https://other-site.net/x">站外</a></body></html>`))
|
||||||
|
case "/avatar.png":
|
||||||
|
_, _ = w.Write([]byte("PNGDATA"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL))
|
||||||
|
|
||||||
|
// 先造出登录态(等价于书源已登录成功)
|
||||||
|
st := svc.newSourceState(ctx, srv.URL)
|
||||||
|
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||||
|
st.flush()
|
||||||
|
|
||||||
|
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
|
||||||
|
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||||
|
URL: srv.URL + "/user", Title: "用户后台",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("打开用户后台失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(userCookie, "qttoken=SESSION_abcdef123456") {
|
||||||
|
t.Fatalf("抓取页面时未带上书源 Cookie: %q", userCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := waitPending(t, svc, sourceID)
|
||||||
|
if page.Mode != browserModeOpen {
|
||||||
|
t.Fatalf("模式 = %q,期望 open", page.Mode)
|
||||||
|
}
|
||||||
|
if page.TargetURL != srv.URL+"/user" {
|
||||||
|
t.Fatalf("原始地址 = %q", page.TargetURL)
|
||||||
|
}
|
||||||
|
snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 已登录内容(证明用的是服务端 Cookie 抓到的页面)
|
||||||
|
if !strings.Contains(snap.HTML, "我的账号") {
|
||||||
|
t.Fatalf("页面不是登录态内容: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
// 资源地址应被改写到同源代理;站外链接保留原样
|
||||||
|
if !strings.Contains(snap.HTML, "/api/reader/browser/asset?") {
|
||||||
|
t.Fatalf("资源地址未改写: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(snap.HTML, "https://other-site.net/x") {
|
||||||
|
t.Fatalf("站外链接不应改写: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
// 注入脚本要带齐三样:DOM 回传、请求代理、以及书源登录态
|
||||||
|
if !strings.Contains(snap.HTML, "data-mebox-browser-bridge") {
|
||||||
|
t.Fatalf("缺少注入脚本: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(snap.HTML, "__mebox_proxy__") {
|
||||||
|
t.Fatal("注入脚本缺少请求代理(页面自己的 fetch/XHR 会因不透明源被 CORS 拦)")
|
||||||
|
}
|
||||||
|
// 页面常用 document.cookie 判断登录态;真实 Cookie 在服务端,
|
||||||
|
// 不预置的话「用户后台」会以为未登录并把浏览器导去 /login
|
||||||
|
if !strings.Contains(snap.HTML, "qttoken=SESSION_abcdef123456") {
|
||||||
|
t.Fatalf("注入脚本未预置书源 Cookie: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
// 相对地址要按书源页面地址解析(沙箱里基地址不是承载地址)
|
||||||
|
if !strings.Contains(snap.HTML, srv.URL) {
|
||||||
|
t.Fatalf("注入脚本未带上页面基地址: %q", snap.HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资源代理要能校验签名并把 Cookie 带上
|
||||||
|
assetURL := extractAssetURL(t, snap.HTML)
|
||||||
|
u, err := url.Parse(assetURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, gotSourceURL, err := svc.VerifyBrowserAsset(
|
||||||
|
u.Query().Get("id"), u.Query().Get("u"), u.Query().Get("s"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("资源签名校验失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(target, "/avatar.png") {
|
||||||
|
t.Fatalf("资源目标 = %q", target)
|
||||||
|
}
|
||||||
|
if gotSourceURL != srv.URL {
|
||||||
|
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
|
||||||
|
}
|
||||||
|
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("拉取资源失败: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK || string(data) != "PNGDATA" {
|
||||||
|
t.Fatalf("资源内容异常: status=%d body=%q", status, data)
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
t.Fatal("资源缺少 Content-Type")
|
||||||
|
}
|
||||||
|
// 伪造资源签名必须被拒
|
||||||
|
if _, _, err := svc.VerifyBrowserAsset(u.Query().Get("id"), u.Query().Get("u"), "deadbeef"); err == nil {
|
||||||
|
t.Fatal("伪造资源签名不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelXHRProxy 页面内的接口请求要由服务端代发并补上书源 Cookie。
|
||||||
|
// 「用户后台」这类页面靠接口取数,iframe 自己的 XHR 带不上 Cookie 也会被 CORS 拦。
|
||||||
|
func TestBrowserPanelXHRProxy(t *testing.T) {
|
||||||
|
var apiCookie string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/user":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<html><head></head><body>后台</body></html>`))
|
||||||
|
case "/api/me":
|
||||||
|
apiCookie = r.Header.Get("Cookie")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if !strings.Contains(apiCookie, "qttoken=") {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"未登录"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"nickname":"tester","vip":true}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL))
|
||||||
|
st := svc.newSourceState(ctx, srv.URL)
|
||||||
|
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||||
|
st.flush()
|
||||||
|
|
||||||
|
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||||
|
URL: srv.URL + "/user", Title: "用户后台",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("打开用户后台失败: %v", err)
|
||||||
|
}
|
||||||
|
page := waitPending(t, svc, sourceID)
|
||||||
|
|
||||||
|
res, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, srv.URL+"/api/me",
|
||||||
|
map[string]string{"X-From-Page": "1"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("转发请求失败: %v", err)
|
||||||
|
}
|
||||||
|
if res.Status != http.StatusOK {
|
||||||
|
t.Fatalf("状态 = %d body=%q", res.Status, res.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(apiCookie, "qttoken=SESSION_abcdef123456") {
|
||||||
|
t.Fatalf("转发时未带上书源 Cookie: %q", apiCookie)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Body, `"nickname":"tester"`) {
|
||||||
|
t.Fatalf("响应体异常: %q", res.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.ContentType, "json") {
|
||||||
|
t.Fatalf("内容类型 = %q", res.ContentType)
|
||||||
|
}
|
||||||
|
if res.Base64 {
|
||||||
|
t.Fatal("JSON 响应不应走 base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知待办:拒绝(链接已过期)
|
||||||
|
if _, err := svc.ProxyBrowserXHR(ctx, "nope", http.MethodGet, srv.URL+"/api/me", nil, ""); err == nil {
|
||||||
|
t.Fatal("未知待办应报错")
|
||||||
|
}
|
||||||
|
// 非 http(s):拒绝,避免被当成任意协议跳板
|
||||||
|
if _, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, "file:///etc/passwd", nil, ""); err == nil {
|
||||||
|
t.Fatal("非 http(s) 地址应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAssetURL 从改写后的页面里取出第一个资源代理地址。
|
||||||
|
func extractAssetURL(t *testing.T, html string) string {
|
||||||
|
t.Helper()
|
||||||
|
idx := strings.Index(html, "/api/reader/browser/asset?")
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("页面里没有资源代理地址: %q", html)
|
||||||
|
}
|
||||||
|
rest := html[idx:]
|
||||||
|
// 到引号/尖括号为止
|
||||||
|
end := strings.IndexAny(rest, `"'< `)
|
||||||
|
if end < 0 {
|
||||||
|
end = len(rest)
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rest[:end], "&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelUnknownSourceIsolated 待办按书源隔离,避免串台。
|
||||||
|
func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||||
|
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
_, _ = svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
page := waitPending(t, svc, sourceID)
|
||||||
|
|
||||||
|
if pages := svc.PendingBrowserPages(readerTestUserID, "other-source"); len(pages) != 0 {
|
||||||
|
t.Fatalf("其它书源不应看到待办: %+v", pages)
|
||||||
|
}
|
||||||
|
// 收尾,避免 goroutine 挂到超时
|
||||||
|
_ = svc.ResolveBrowser(page.ID, readerTestUserID, "<span id=\"serverValue\">x</span>", "", false)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("未解除阻塞")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import "encoding/base64"
|
||||||
|
|
||||||
|
func base64EncodeStr(s string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,580 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AnalyzeRule) makeDeps() *RuleDeps {
|
||||||
|
return &RuleDeps{
|
||||||
|
JS: a.evalJS,
|
||||||
|
Rule: func(rule string) (string, error) { return a.GetString(rule, nil, 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())
|
||||||
|
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 {
|
||||||
|
var urlList []string
|
||||||
|
if lst, ok := result.([]string); ok {
|
||||||
|
for _, u := range lst {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
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) == "" {
|
||||||
|
return a.baseUrl, 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())
|
||||||
|
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,560 @@
|
|||||||
|
package rule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件对应 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)
|
||||||
|
})
|
||||||
|
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||||
|
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||||
|
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||||
|
|
||||||
|
// ── 书源会话状态(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 string) func(goja.FunctionCall) goja.Value {
|
||||||
|
return func(call goja.FunctionCall) goja.Value {
|
||||||
|
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
|
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||||
|
"openVideoPlayer", "getVerificationCode",
|
||||||
|
"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 抓取,暂不支持")
|
||||||
|
)
|
||||||
@@ -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,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,193 @@
|
|||||||
|
package rule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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")。
|
||||||
|
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 anyToString(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,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,445 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 对应 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:GET 请求的 query 必须被保留并重编码。
|
||||||
|
//
|
||||||
|
// 回归:executeWithState 曾拿 req.URLNoQuery 当请求目标。对 GET 来说那是**去掉
|
||||||
|
// query** 的裸路径,于是所有「参数写在 query 里」的请求(搜索关键词、分页、
|
||||||
|
// 平台筛选等)到了站点只剩路径。光遇聚合的 /search 因此收到空参数,返回
|
||||||
|
// `{"code":-1,"msg":"参数不能为空"}`,表现为「搜索无结果」。
|
||||||
|
// legado 的 GET 走 `get(urlNoQuery, encodedQuery)`,两者拼起来才是完整地址。
|
||||||
|
|
||||||
|
// queryEchoSource 构造一个把查询参数回显成 JSON 的书源。
|
||||||
|
func queryEchoSource(serverURL string) string {
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": serverURL,
|
||||||
|
"bookSourceName": "query 回显源",
|
||||||
|
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
|
||||||
|
"ruleSearch": map[string]any{
|
||||||
|
"bookList": "$.data",
|
||||||
|
"name": "$.book_name",
|
||||||
|
"author": "$.author",
|
||||||
|
"bookUrl": "$.book_url",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(src)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSendsQueryString(t *testing.T) {
|
||||||
|
var gotURI, gotQuery string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotURI = r.URL.RequestURI()
|
||||||
|
gotQuery = r.URL.Query().Get("q")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"book_name":"宠魅","author":"安橙花","book_url":"/book/1"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||||
|
|
||||||
|
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
t.Fatalf("书源被跳过: %+v", skipped)
|
||||||
|
}
|
||||||
|
if len(books) != 1 {
|
||||||
|
t.Fatalf("应搜到 1 条,实际 %d 条", len(books))
|
||||||
|
}
|
||||||
|
if books[0].Name != "宠魅" || books[0].Author != "安橙花" {
|
||||||
|
t.Fatalf("书目解析异常: %+v", books[0])
|
||||||
|
}
|
||||||
|
// 服务端必须真的收到 query —— 这正是之前丢掉的部分
|
||||||
|
if gotQuery != "宠魅" {
|
||||||
|
t.Fatalf("服务端收到的 q = %q,请求 URI = %q", gotQuery, gotURI)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotURI, "page=1") {
|
||||||
|
t.Fatalf("分页参数丢失: %q", gotURI)
|
||||||
|
}
|
||||||
|
// 中文必须被百分号编码后才上线(原始 UTF-8 字节进请求行是非法的)
|
||||||
|
if strings.Contains(gotURI, "宠") {
|
||||||
|
t.Fatalf("query 未做百分号编码: %q", gotURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchResponseUsesEmptyArrays 搜索返回的列表字段必须是空切片而不是 nil。
|
||||||
|
//
|
||||||
|
// 回归:skipped 为 nil 切片时会被编码成 JSON null,前端 `skipped.length`
|
||||||
|
// 直接抛 TypeError,整页被错误边界接管(表现为「页面加载失败」)。
|
||||||
|
// handler 把这两个值原样交给 c.JSON,所以按同样的方式编码即可验证契约。
|
||||||
|
func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||||
|
|
||||||
|
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]any{"books": books, "skipped": skipped})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), `"skipped":null`) {
|
||||||
|
t.Fatalf("skipped 被编码成 null(前端会按数组用): %s", payload)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), `"books":null`) {
|
||||||
|
t.Fatalf("books 被编码成 null: %s", payload)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(payload), `"skipped":[]`) {
|
||||||
|
t.Fatalf("skipped 应为空数组: %s", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchPreservesExistingPercentEncoding 已经编码好的 query 不应被二次编码。
|
||||||
|
func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||||
|
var gotQuery string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotQuery = r.URL.Query().Get("q")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": srv.URL,
|
||||||
|
"bookSourceName": "已编码 query 源",
|
||||||
|
"searchUrl": srv.URL + "/search?q=%E5%AE%A0%E9%AD%85&page={{page}}",
|
||||||
|
"ruleSearch": map[string]any{
|
||||||
|
"bookList": "$.data",
|
||||||
|
"name": "$.book_name",
|
||||||
|
"bookUrl": "$.book_url",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(src)
|
||||||
|
_ = prepareLoginSource(t, svc, string(out))
|
||||||
|
|
||||||
|
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
|
||||||
|
t.Fatalf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
if gotQuery != "宠魅" {
|
||||||
|
t.Fatalf("已编码的 query 被破坏: %q", gotQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package reader — legado 书源兼容的阅读子系统服务层。
|
||||||
|
// 本文件对应 legado data/entities/BookSource.kt 的 JSON 结构。
|
||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BookSource 书源 JSON 结构(字段与 legado 实体一致,未知字段忽略)。
|
||||||
|
type BookSource struct {
|
||||||
|
RawJSON string `json:"-"`
|
||||||
|
BookSourceURL string `json:"bookSourceUrl"`
|
||||||
|
BookSourceName string `json:"bookSourceName"`
|
||||||
|
BookSourceGroup *string `json:"bookSourceGroup"`
|
||||||
|
BookSourceType *int `json:"bookSourceType"` // 0文本 1音频 2图片 3文件 4视频
|
||||||
|
BookURLPattern *string `json:"bookUrlPattern"`
|
||||||
|
CustomOrder *int `json:"customOrder"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
EnabledExplore *bool `json:"enabledExplore"`
|
||||||
|
EnabledCookieJar *bool `json:"enabledCookieJar"`
|
||||||
|
ConcurrentRate *string `json:"concurrentRate"`
|
||||||
|
Header *string `json:"header"`
|
||||||
|
LoginURL *string `json:"loginUrl"`
|
||||||
|
LoginUI *string `json:"loginUi"`
|
||||||
|
LoginCheckJS *string `json:"loginCheckJs"`
|
||||||
|
JSLib *string `json:"jsLib"`
|
||||||
|
BookSourceComment *string `json:"bookSourceComment"`
|
||||||
|
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||||
|
RespondTime *int64 `json:"respondTime"`
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
ExploreURL *string `json:"exploreUrl"`
|
||||||
|
SearchURL *string `json:"searchUrl"`
|
||||||
|
RuleExplore *ExploreRule `json:"ruleExplore"`
|
||||||
|
RuleSearch *SearchRule `json:"ruleSearch"`
|
||||||
|
RuleBookInfo *BookInfoRule `json:"ruleBookInfo"`
|
||||||
|
RuleToc *TocRule `json:"ruleToc"`
|
||||||
|
RuleContent *ContentRule `json:"ruleContent"`
|
||||||
|
VariableComment *string `json:"variableComment"`
|
||||||
|
Variables map[string]any `json:"-"`
|
||||||
|
RawVariables *string `json:"variables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnabledCookieJarOrDefault 是否启用 Cookie 自动携带(legado 默认 true)。
|
||||||
|
func (b *BookSource) EnabledCookieJarOrDefault() bool {
|
||||||
|
return b.EnabledCookieJar == nil || *b.EnabledCookieJar
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLogin 是否声明了登录能力(登录需要 loginUrl 的 JS 或 loginUi 表单)。
|
||||||
|
func (b *BookSource) HasLogin() bool {
|
||||||
|
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||||
|
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||||
|
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||||
|
func (b *BookSource) LoginJS() string {
|
||||||
|
return stripJSWrapper(SPtr(b.LoginURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||||
|
func stripJSWrapper(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "@js:") {
|
||||||
|
return s[len("@js:"):]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "<js>") {
|
||||||
|
s = s[len("<js>"):]
|
||||||
|
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||||
|
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRule 搜索规则。
|
||||||
|
type SearchRule struct {
|
||||||
|
CheckKeyWord *string `json:"checkKeyWord"`
|
||||||
|
BookList *string `json:"bookList"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
WordCount *string `json:"wordCount"`
|
||||||
|
LastChapter *string `json:"lastChapter"`
|
||||||
|
Intro *string `json:"intro"`
|
||||||
|
CoverURL *string `json:"coverUrl"`
|
||||||
|
BookURL *string `json:"bookUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookInfoRule 详情规则。
|
||||||
|
type BookInfoRule struct {
|
||||||
|
Init *string `json:"init"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
WordCount *string `json:"wordCount"`
|
||||||
|
LastChapter *string `json:"lastChapter"`
|
||||||
|
Intro *string `json:"intro"`
|
||||||
|
CoverURL *string `json:"coverUrl"`
|
||||||
|
TocURL *string `json:"tocUrl"`
|
||||||
|
CanReName *string `json:"canReName"`
|
||||||
|
DownloadUrls *string `json:"downloadUrls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TocRule 目录规则。
|
||||||
|
type TocRule struct {
|
||||||
|
PreUpdateJs *string `json:"preUpdateJs"`
|
||||||
|
ChapterList *string `json:"chapterList"`
|
||||||
|
ChapterName *string `json:"chapterName"`
|
||||||
|
ChapterURL *string `json:"chapterUrl"`
|
||||||
|
IsVolume *string `json:"isVolume"`
|
||||||
|
UpdateTime *string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentRule 正文规则。
|
||||||
|
type ContentRule struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
NextContentURL *string `json:"nextContentUrl"`
|
||||||
|
WebJs *string `json:"webJs"`
|
||||||
|
SourceRegex *string `json:"sourceRegex"`
|
||||||
|
ReplaceRegex *string `json:"replaceRegex"`
|
||||||
|
ImageStyle *string `json:"imageStyle"`
|
||||||
|
PayAction *string `json:"payAction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExploreRule 发现规则。
|
||||||
|
type ExploreRule struct {
|
||||||
|
BookList *string `json:"bookList"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
WordCount *string `json:"wordCount"`
|
||||||
|
LastChapter *string `json:"lastChapter"`
|
||||||
|
Intro *string `json:"intro"`
|
||||||
|
CoverURL *string `json:"coverUrl"`
|
||||||
|
BookURL *string `json:"bookUrl"`
|
||||||
|
ExploreURL *string `json:"exploreUrl"`
|
||||||
|
ExploreKinds *string `json:"exploreKinds"`
|
||||||
|
CheckKeyWord *string `json:"checkKeyWord"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||||
|
func ParseBookSource(raw string) (*BookSource, error) {
|
||||||
|
var bs BookSource
|
||||||
|
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bs.RawJSON = raw
|
||||||
|
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(*bs.RawVariables), &bs.Variables)
|
||||||
|
}
|
||||||
|
return &bs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||||
|
func (b *BookSource) SourceProps() map[string]any {
|
||||||
|
if b.RawJSON == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal([]byte(b.RawJSON), &m) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type 返回书源类型(默认文本)。
|
||||||
|
func (b *BookSource) Type() int {
|
||||||
|
if b.BookSourceType == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *b.BookSourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPtr 取字符串指针字段值。
|
||||||
|
func SPtr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPtr 取整型指针字段值。
|
||||||
|
func IPtr(i *int) int {
|
||||||
|
if i == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *i
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:把书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)落到 DB。
|
||||||
|
// 对应 legado 的 CacheManager + CookieStore,按书源 URL 隔离,
|
||||||
|
// 使登录态在服务端重启后依然有效。
|
||||||
|
|
||||||
|
// sourceState 实现 rule.SourceState,按书源 URL 读写 ReaderSourceState。
|
||||||
|
// 写入是"读-改-写":一次性把四个字段整体落库,保证并发下不丢字段。
|
||||||
|
type sourceState struct {
|
||||||
|
svc *ReaderService
|
||||||
|
ctx context.Context
|
||||||
|
sourceURL string
|
||||||
|
// 进程内累积区:一次规则执行内可能多次读写,先落内存再统一 flush。
|
||||||
|
variable string
|
||||||
|
loginInfo string
|
||||||
|
loginHeader string
|
||||||
|
cookies map[string]string // domain → "k=v; k=v"
|
||||||
|
loaded bool
|
||||||
|
|
||||||
|
toasts []string
|
||||||
|
browsers []rule.BrowserRequest
|
||||||
|
// uiRefresh 书源通过 java.reLoginView / refreshExplore / upLoginData
|
||||||
|
// 请求重新渲染登录表单(对应 legado 直接操作对话框控件)。
|
||||||
|
uiRefresh bool
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSourceState 载入指定书源的会话状态(含解密)。
|
||||||
|
func (s *ReaderService) newSourceState(ctx context.Context, sourceURL string) *sourceState {
|
||||||
|
st := &sourceState{svc: s, ctx: ctx, sourceURL: sourceURL, cookies: map[string]string{}}
|
||||||
|
if s.repo == nil || sourceURL == "" {
|
||||||
|
st.loaded = true
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
rec, err := s.repo.GetSourceState(ctx, sourceURL)
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
st.loaded = true
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
st.variable = s.decrypt(rec.Variable)
|
||||||
|
st.loginInfo = s.decrypt(rec.LoginInfo)
|
||||||
|
st.loginHeader = s.decrypt(rec.LoginHeader)
|
||||||
|
if ck := s.decrypt(rec.Cookies); ck != "" {
|
||||||
|
_ = json.Unmarshal([]byte(ck), &st.cookies)
|
||||||
|
}
|
||||||
|
st.loaded = true
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) GetVariable() string { return st.variable }
|
||||||
|
|
||||||
|
func (st *sourceState) SetVariable(v string) {
|
||||||
|
if st.variable == v {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.variable = v
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVariableKey / SetVariableKey 以键为单位读写源变量 map,
|
||||||
|
// 对应 legado source.variableMap(规则里的 @get:{} / @put:{} 与 java.get/put 走这里)。
|
||||||
|
func (st *sourceState) GetVariableKey(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if json.Unmarshal([]byte(st.variable), &m) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) SetVariableKey(key, value string) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := map[string]string{}
|
||||||
|
_ = json.Unmarshal([]byte(st.variable), &m)
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
m[key] = value
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.SetVariable(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) GetLoginInfo() string { return st.loginInfo }
|
||||||
|
|
||||||
|
func (st *sourceState) SetLoginInfo(v string) {
|
||||||
|
if st.loginInfo == v {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.loginInfo = v
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) GetLoginHeader() string { return st.loginHeader }
|
||||||
|
|
||||||
|
func (st *sourceState) SetLoginHeader(v string) {
|
||||||
|
if st.loginHeader == v {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.loginHeader = v
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||||
|
|
||||||
|
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||||
|
domain := rule.CookieDomain(rawURL)
|
||||||
|
if domain == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, ok := st.cookies[domain]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return rule.ParseCookie(raw)[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||||
|
domain := rule.CookieDomain(rawURL)
|
||||||
|
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||||
|
if merged == st.cookies[domain] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.cookies[domain] = merged
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) RemoveCookie(rawURL string) {
|
||||||
|
domain := rule.CookieDomain(rawURL)
|
||||||
|
if domain == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := st.cookies[domain]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(st.cookies, domain)
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) Toast(msg string) { st.toasts = append(st.toasts, msg) }
|
||||||
|
|
||||||
|
// RequestUIRefresh 实现 rule.UIState:书源要求重画登录表单。
|
||||||
|
func (st *sourceState) RequestUIRefresh() { st.uiRefresh = true }
|
||||||
|
|
||||||
|
// ApplyLoginData 实现 rule.UIState:把书源给出的值合并进已保存的登录信息。
|
||||||
|
func (st *sourceState) ApplyLoginData(data map[string]string) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur := map[string]string{}
|
||||||
|
if st.loginInfo != "" {
|
||||||
|
_ = json.Unmarshal([]byte(st.loginInfo), &cur)
|
||||||
|
}
|
||||||
|
for k, v := range data {
|
||||||
|
cur[k] = v
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(cur); err == nil {
|
||||||
|
st.SetLoginInfo(string(b))
|
||||||
|
}
|
||||||
|
st.uiRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIRefreshRequested 返回并清空「重画登录表单」标记。
|
||||||
|
func (st *sourceState) UIRefreshRequested() bool {
|
||||||
|
out := st.uiRefresh
|
||||||
|
st.uiRefresh = false
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *sourceState) OpenBrowser(url, title string) {
|
||||||
|
st.browsers = append(st.browsers, rule.BrowserRequest{URL: url, Title: title})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush 把累积状态落库(登录信息与 Cookie 加密存储)。
|
||||||
|
func (st *sourceState) flush() {
|
||||||
|
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookiesJSON := ""
|
||||||
|
if len(st.cookies) > 0 {
|
||||||
|
if b, err := json.Marshal(st.cookies); err == nil {
|
||||||
|
cookiesJSON = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := &model.ReaderSourceState{
|
||||||
|
SourceURL: st.sourceURL,
|
||||||
|
Variable: st.variable,
|
||||||
|
LoginInfo: st.svc.encrypt(st.loginInfo),
|
||||||
|
LoginHeader: st.svc.encrypt(st.loginHeader),
|
||||||
|
Cookies: st.svc.encrypt(cookiesJSON),
|
||||||
|
}
|
||||||
|
if err := st.svc.repo.SaveSourceState(st.ctx, rec); err != nil {
|
||||||
|
if st.svc.log != nil {
|
||||||
|
st.svc.log.Warn("reader: 保存书源会话状态失败: " + err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||||
|
func (st *sourceState) snapshotCookies() map[string]string {
|
||||||
|
out := make(map[string]string, len(st.cookies))
|
||||||
|
for d, c := range st.cookies {
|
||||||
|
out[d] = c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||||
|
func (st *sourceState) clearCookies() {
|
||||||
|
if len(st.cookies) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.cookies = map[string]string{}
|
||||||
|
st.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieForRequest 返回应附加到该请求的 Cookie 串:
|
||||||
|
// 会话 Cookie 优先,其次是 loginHeader 中显式声明的 Cookie。
|
||||||
|
func (st *sourceState) CookieForRequest(rawURL string) string {
|
||||||
|
if st == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cookie := st.GetCookie(rawURL)
|
||||||
|
if h := strings.TrimSpace(st.loginHeader); h != "" {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal([]byte(h), &m) == nil {
|
||||||
|
for k, v := range m {
|
||||||
|
if strings.EqualFold(k, "cookie") {
|
||||||
|
extra := strings.TrimSpace(toStringVal(v))
|
||||||
|
if extra != "" {
|
||||||
|
cookie = rule.MergeCookie(cookie, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHeaderMap 返回 loginHeader 的解析结果(除 Cookie 外的头)。
|
||||||
|
func (st *sourceState) LoginHeaderMap() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
if st == nil || strings.TrimSpace(st.loginHeader) == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal([]byte(st.loginHeader), &m) != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = toStringVal(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringVal(v any) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt / decrypt 复用全局 CryptoService(密钥来自 JWTSecret)。
|
||||||
|
func (s *ReaderService) encrypt(plain string) string {
|
||||||
|
if s.crypto == nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
return s.crypto.Encrypt(plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReaderService) decrypt(value string) string {
|
||||||
|
if s.crypto == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return s.crypto.Decrypt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ rule.SourceState = (*sourceState)(nil)
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/helper"
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||||
@@ -70,6 +71,7 @@ type Container struct {
|
|||||||
Danmaku *DanmakuService
|
Danmaku *DanmakuService
|
||||||
Strm *StrmService
|
Strm *StrmService
|
||||||
Cloud115 *Cloud115PlaybackService
|
Cloud115 *Cloud115PlaybackService
|
||||||
|
Reader *reader.ReaderService
|
||||||
Database *DatabaseAdminService
|
Database *DatabaseAdminService
|
||||||
FFTools *FFmpegToolsService
|
FFTools *FFmpegToolsService
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/helper"
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader"
|
||||||
)
|
)
|
||||||
|
|
||||||
type serviceContainerBuilder struct {
|
type serviceContainerBuilder struct {
|
||||||
@@ -82,6 +83,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
|
|||||||
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
|
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
|
||||||
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
|
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
|
||||||
b.c.Danmaku = NewDanmakuService(b.log, b.repos)
|
b.c.Danmaku = NewDanmakuService(b.log, b.repos)
|
||||||
|
b.c.Reader = reader.NewReaderService(b.cfg, b.log, b.repos)
|
||||||
|
|
||||||
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
|
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
|
||||||
b.c.Scraper = NewScraperService(
|
b.c.Scraper = NewScraperService(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { appRoutes, type AppRoute } from './appRoutes'
|
|||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||||
|
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||||
|
|
||||||
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
||||||
|
|
||||||
@@ -62,6 +63,17 @@ export default function App() {
|
|||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
{/* 阅读独立布局(不套影视 Layout,全屏沉浸) */}
|
||||||
|
<Route
|
||||||
|
path="/reader/*"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ReaderRoutes />
|
||||||
|
</Suspense>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { LONG_REQUEST_TIMEOUT, api } from './client'
|
||||||
|
|
||||||
|
// 阅读子系统 API(/api/reader/*),字段与后端 model/reader.go 对齐。
|
||||||
|
|
||||||
|
export interface ReaderSearchOrigin {
|
||||||
|
source_id: string
|
||||||
|
origin: string
|
||||||
|
origin_name: string
|
||||||
|
origin_type: number
|
||||||
|
book_url: string
|
||||||
|
latest_chapter: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderSearchBook {
|
||||||
|
name: string
|
||||||
|
author: string
|
||||||
|
kind: string
|
||||||
|
word_count: string
|
||||||
|
latest_chapter: string
|
||||||
|
intro: string
|
||||||
|
cover_url: string
|
||||||
|
book_url: string
|
||||||
|
origins: ReaderSearchOrigin[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderSearchSkipped {
|
||||||
|
source_id: string
|
||||||
|
origin_name: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderSource {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
type: number
|
||||||
|
source_url: string
|
||||||
|
enabled: boolean
|
||||||
|
enabled_explore: boolean
|
||||||
|
custom_order: number
|
||||||
|
weight: number
|
||||||
|
comment: string
|
||||||
|
last_update_time: number
|
||||||
|
respond_time: number
|
||||||
|
// 书源声明了登录能力(loginUrl / loginUi)
|
||||||
|
has_login: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderBook {
|
||||||
|
id: string
|
||||||
|
origin: string
|
||||||
|
origin_name: string
|
||||||
|
book_url: string
|
||||||
|
toc_url: string
|
||||||
|
name: string
|
||||||
|
author: string
|
||||||
|
kind: string
|
||||||
|
cover_url: string
|
||||||
|
intro: string
|
||||||
|
type: number
|
||||||
|
latest_chapter_title: string
|
||||||
|
total_chapter_num: number
|
||||||
|
dur_chapter_index: number
|
||||||
|
dur_chapter_pos: number
|
||||||
|
dur_chapter_title: string
|
||||||
|
dur_chapter_time: number
|
||||||
|
order: number
|
||||||
|
/** 本地导入书籍(TXT / EPUB / 有声书目录),正文在服务端,不走书源。 */
|
||||||
|
is_local: boolean
|
||||||
|
/** 原地引用服务器上已有的文件/目录:移出书架只解除引用,不删除源文件。 */
|
||||||
|
local_external: boolean
|
||||||
|
/** 听书跳过片头/片尾秒数(0 表示不跳过,对应 legado Book.openCredits/closeCredits)。 */
|
||||||
|
open_credits: number
|
||||||
|
close_credits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderBookInfo {
|
||||||
|
name: string
|
||||||
|
author: string
|
||||||
|
kind: string
|
||||||
|
word_count: string
|
||||||
|
latest_chapter: string
|
||||||
|
intro: string
|
||||||
|
cover_url: string
|
||||||
|
toc_url: string
|
||||||
|
book_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderTocChapter {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
is_volume: boolean
|
||||||
|
update_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderChapter {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
is_volume: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderChapterContent {
|
||||||
|
type: 'text' | 'audio' | 'image'
|
||||||
|
content?: string
|
||||||
|
tracks?: string[]
|
||||||
|
images?: string[]
|
||||||
|
image_style?: string
|
||||||
|
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||||
|
transcoding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderReplaceRule {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
pattern: string
|
||||||
|
replacement: string
|
||||||
|
scope: string
|
||||||
|
scope_title: boolean
|
||||||
|
scope_content: boolean
|
||||||
|
exclude_scope: string
|
||||||
|
is_enabled: boolean
|
||||||
|
is_regex: boolean
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 书源登录 ──
|
||||||
|
|
||||||
|
export interface ReaderLoginField {
|
||||||
|
name: string
|
||||||
|
type: 'text' | 'password' | 'button' | 'toggle' | 'select'
|
||||||
|
action?: string
|
||||||
|
chars?: string[]
|
||||||
|
default?: string
|
||||||
|
viewName?: string
|
||||||
|
style?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderSourceLogin {
|
||||||
|
source_id: string
|
||||||
|
source_name: string
|
||||||
|
has_login_js: boolean
|
||||||
|
login_js?: string
|
||||||
|
fields: ReaderLoginField[]
|
||||||
|
values: Record<string, string>
|
||||||
|
cookies: Record<string, string>
|
||||||
|
variable: string
|
||||||
|
variable_comment?: string
|
||||||
|
logged_in: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderBrowserRequest {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书源 JS 交给宿主浏览器承载的一个页面(java.startBrowser / startBrowserAwait)。 */
|
||||||
|
export interface ReaderBrowserPage {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||||
|
mode: 'wait' | 'open'
|
||||||
|
/** iframe 承载地址(同源,带签名)。 */
|
||||||
|
page_url: string
|
||||||
|
refetch: boolean
|
||||||
|
source_id: string
|
||||||
|
target_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderLoginResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
toasts?: string[]
|
||||||
|
browsers?: ReaderBrowserRequest[]
|
||||||
|
/** 书源要求重新渲染登录表单(java.reLoginView / refreshExplore / upLoginData)。 */
|
||||||
|
ui_refresh?: boolean
|
||||||
|
values: Record<string, string>
|
||||||
|
cookies: Record<string, string>
|
||||||
|
logged_in: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const
|
||||||
|
|
||||||
|
// 登录动作可能阻塞等待用户在页面里操作(java.startBrowserAwait),
|
||||||
|
// 服务端上限 10 分钟,这里留出余量,避免 axios 先超时把请求掐掉。
|
||||||
|
const browserWaitOpts = { timeout: 11 * 60_000 } as const
|
||||||
|
|
||||||
|
export const readerAPI = {
|
||||||
|
// ── 书源 ──
|
||||||
|
listSources: () => api.get<{ sources: ReaderSource[] }>('/reader/sources').then((r) => r.data.sources),
|
||||||
|
importSources: (text: string) =>
|
||||||
|
api.post<{ imported: number }>('/reader/sources/import', { text }, longOpts).then((r) => r.data.imported),
|
||||||
|
setSourceEnabled: (id: string, enabled: boolean) => api.patch(`/reader/sources/${id}`, { enabled }),
|
||||||
|
deleteSource: (id: string) => api.delete(`/reader/sources/${id}`),
|
||||||
|
debugSource: (id: string, key: string) =>
|
||||||
|
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
|
||||||
|
|
||||||
|
// ── 书源登录与源变量 ──
|
||||||
|
// 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态)
|
||||||
|
sourceLogin: (id: string) => api.get<ReaderSourceLogin>(`/reader/sources/${id}/login`).then((r) => r.data),
|
||||||
|
// 执行登录动作:action 为空表示执行 loginUrl 的 login()(确认登录)
|
||||||
|
runSourceLogin: (id: string, body: { action?: string; fields?: Record<string, string> }) =>
|
||||||
|
api.post<ReaderLoginResult>(`/reader/sources/${id}/login`, body, browserWaitOpts).then((r) => r.data),
|
||||||
|
// 书源 JS 的宿主浏览器:轮询待用户完成的页面(startBrowserAwait 会阻塞在服务端)
|
||||||
|
browserPending: (sourceId: string) =>
|
||||||
|
api
|
||||||
|
.get<{ pages: ReaderBrowserPage[] }>('/reader/browser/pending', { params: { source_id: sourceId } })
|
||||||
|
.then((r) => r.data.pages ?? []),
|
||||||
|
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
||||||
|
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
||||||
|
api.post('/reader/browser/result', body).then((r) => r.data),
|
||||||
|
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
||||||
|
browserXHR: (body: {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string
|
||||||
|
}) =>
|
||||||
|
api
|
||||||
|
.post<{ status: number; content_type: string; body: string; base64: boolean }>(
|
||||||
|
'/reader/browser/xhr',
|
||||||
|
body,
|
||||||
|
longOpts,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
// 仅保存表单值,不触发登录
|
||||||
|
saveSourceLoginInfo: (id: string, fields: Record<string, string>) =>
|
||||||
|
api.put(`/reader/sources/${id}/login-info`, { fields }),
|
||||||
|
logoutSource: (id: string) => api.delete(`/reader/sources/${id}/login`),
|
||||||
|
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||||
|
|
||||||
|
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||||
|
search: (key: string) =>
|
||||||
|
api
|
||||||
|
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||||
|
'/reader/search',
|
||||||
|
{ key },
|
||||||
|
longOpts,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
bookInfo: (params: { source_id?: string; source_url?: string; book_url: string }) =>
|
||||||
|
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||||
|
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
|
||||||
|
api
|
||||||
|
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data.chapters),
|
||||||
|
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
|
||||||
|
api
|
||||||
|
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
// ── 书架 ──
|
||||||
|
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
|
||||||
|
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||||
|
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||||
|
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||||
|
uploadLocalBook: (file: File, onProgress?: (percent: number) => void) =>
|
||||||
|
api
|
||||||
|
.post<ReaderBook>('/reader/local/books', (() => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return form
|
||||||
|
})(), {
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (!onProgress || !e.total) return
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
/** 从服务器已有文件导入书籍(TXT / EPUB),原地引用不复制。仅管理员。 */
|
||||||
|
importLocalBookFromPath: (path: string) =>
|
||||||
|
api
|
||||||
|
.post<ReaderBook>('/reader/local/books/from-path', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
/** 把服务器上的一本目录导入为有声书(音频文件 + .strm 播放指针)。仅管理员。 */
|
||||||
|
importLocalAudioDir: (path: string) =>
|
||||||
|
api
|
||||||
|
.post<ReaderBook>('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
|
||||||
|
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
|
||||||
|
api.put(`/reader/books/${id}/progress`, body),
|
||||||
|
// 听书跳过片头/片尾(秒,0 不跳过)
|
||||||
|
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||||
|
api.put(`/reader/books/${id}/audio-config`, body),
|
||||||
|
listChapters: (id: string) =>
|
||||||
|
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||||
|
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||||
|
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||||
|
bookContent: (id: string, chapter: number) =>
|
||||||
|
api
|
||||||
|
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
// ── 替换净化规则 ──
|
||||||
|
listReplaceRules: () =>
|
||||||
|
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
|
||||||
|
createReplaceRule: (body: ReplaceRuleInput) =>
|
||||||
|
api.post<ReaderReplaceRule>('/reader/replace-rules', body).then((r) => r.data),
|
||||||
|
updateReplaceRule: (id: string, body: ReplaceRuleInput) => api.patch(`/reader/replace-rules/${id}`, body),
|
||||||
|
deleteReplaceRule: (id: string) => api.delete(`/reader/replace-rules/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceRuleInput {
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
pattern: string
|
||||||
|
replacement: string
|
||||||
|
scope: string
|
||||||
|
scope_title: boolean
|
||||||
|
scope_content: boolean
|
||||||
|
exclude_scope: string
|
||||||
|
is_enabled: boolean
|
||||||
|
is_regex: boolean
|
||||||
|
timeout_millisecond: number
|
||||||
|
order: number
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import { prefetchCommonRouteChunks } from '../appRoutes'
|
import { prefetchCommonRouteChunks } from '../appRoutes'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { usePlayProfileStore } from '../stores/playProfile'
|
import { usePlayProfileStore } from '../stores/playProfile'
|
||||||
|
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||||
import {
|
import {
|
||||||
LayoutHeader,
|
LayoutHeader,
|
||||||
LayoutSidebars,
|
LayoutSidebars,
|
||||||
@@ -48,6 +49,7 @@ export function Layout() {
|
|||||||
|
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||||
const theme = useThemeMode()
|
const theme = useThemeMode()
|
||||||
@@ -61,7 +63,9 @@ export function Layout() {
|
|||||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||||
const hideSearch = location.pathname.startsWith('/settings')
|
const hideSearch = location.pathname.startsWith('/settings')
|
||||||
const isPlayPage = isPlayerRoute(location.pathname)
|
const isPlayPage = isPlayerRoute(location.pathname)
|
||||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
// 阅读模式:首页切到阅读后,影视那一套壳(媒体搜索、账号菜单、底部导航)都不该出现
|
||||||
|
const readingMode = homeMode === 'reading' && location.pathname === '/'
|
||||||
|
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname) && !readingMode
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||||
@@ -84,6 +88,8 @@ export function Layout() {
|
|||||||
onLogout={closeProfileAndLogout}
|
onLogout={closeProfileAndLogout}
|
||||||
showSidebar={showSidebar}
|
showSidebar={showSidebar}
|
||||||
hideSearch={hideSearch}
|
hideSearch={hideSearch}
|
||||||
|
readingMode={readingMode}
|
||||||
|
showReaderToggle={location.pathname === '/'}
|
||||||
pathname={location.pathname}
|
pathname={location.pathname}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||||
|
|
||||||
import { ARTWORK, imageURL } from '../api/client'
|
import { ARTWORK, imageURL } from '../api/client'
|
||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
|
import { readerAPI, type ReaderBook } from '../api/reader'
|
||||||
import type { Media, PlayProfile, User } from '../types'
|
import type { Media, PlayProfile, User } from '../types'
|
||||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||||
import { resolveHeaderBack } from './layoutNavigation'
|
import { resolveHeaderBack } from './layoutNavigation'
|
||||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||||
|
import { LayoutReaderModeToggle } from './LayoutReaderModeToggle'
|
||||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||||
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ type LayoutHeaderProps = {
|
|||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
showSidebar?: boolean
|
showSidebar?: boolean
|
||||||
hideSearch?: boolean
|
hideSearch?: boolean
|
||||||
|
/** 阅读模式:顶部搜索换成书搜索,并保留账号菜单。 */
|
||||||
|
readingMode?: boolean
|
||||||
|
/** 是否显示顶栏的「影视 / 阅读」图标切换(只在首页)。 */
|
||||||
|
showReaderToggle?: boolean
|
||||||
pathname?: string
|
pathname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +50,8 @@ export function LayoutHeader({
|
|||||||
onLogout,
|
onLogout,
|
||||||
showSidebar,
|
showSidebar,
|
||||||
hideSearch,
|
hideSearch,
|
||||||
|
readingMode,
|
||||||
|
showReaderToggle,
|
||||||
pathname = '',
|
pathname = '',
|
||||||
}: LayoutHeaderProps) {
|
}: LayoutHeaderProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -85,9 +93,11 @@ export function LayoutHeader({
|
|||||||
|
|
||||||
{/* Middle: Search Box */}
|
{/* Middle: Search Box */}
|
||||||
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
|
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
|
||||||
{!hideSearch && <LayoutHeaderSearch />}
|
{readingMode ? <LayoutHeaderBookSearch /> : !hideSearch && <LayoutHeaderSearch />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showReaderToggle && <LayoutReaderModeToggle />}
|
||||||
|
|
||||||
{/* Right: Actions (Theme Toggle & User Menu) */}
|
{/* Right: Actions (Theme Toggle & User Menu) */}
|
||||||
<LayoutHeaderActions
|
<LayoutHeaderActions
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
@@ -108,6 +118,191 @@ export function LayoutHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 阅读模式的顶部搜索:先搜书架(本地即时过滤),
|
||||||
|
// 再给一个「在书源中搜索」的入口跳到多源聚合搜索页。
|
||||||
|
function LayoutHeaderBookSearch() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [books, setBooks] = useState<ReaderBook[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true)
|
||||||
|
if (books !== null || loading) return
|
||||||
|
setLoading(true)
|
||||||
|
readerAPI
|
||||||
|
.listBooks()
|
||||||
|
.then(setBooks)
|
||||||
|
.catch(() => setBooks([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => setIsOpen(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) close()
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onPointerDown)
|
||||||
|
return () => document.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const keyword = query.trim().toLowerCase()
|
||||||
|
const matched = (books ?? [])
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
!keyword ||
|
||||||
|
b.name.toLowerCase().includes(keyword) ||
|
||||||
|
(b.author ?? '').toLowerCase().includes(keyword),
|
||||||
|
)
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
const openBook = (book: ReaderBook) => {
|
||||||
|
close()
|
||||||
|
setQuery('')
|
||||||
|
navigate(`/reader/view/${book.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchSources = () => {
|
||||||
|
const key = query.trim()
|
||||||
|
if (!key) return
|
||||||
|
close()
|
||||||
|
navigate(`/reader/search?key=${encodeURIComponent(key)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setIsOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={openDropdown}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (matched.length > 0) openBook(matched[0])
|
||||||
|
else searchSources()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="搜索书籍…"
|
||||||
|
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||||
|
) : query ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('')
|
||||||
|
inputRef.current?.focus()
|
||||||
|
setIsOpen(true)
|
||||||
|
}}
|
||||||
|
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||||
|
aria-label="清空"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl">
|
||||||
|
{books === null || loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
|
||||||
|
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
|
||||||
|
正在加载书架…
|
||||||
|
</div>
|
||||||
|
) : matched.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||||
|
{query.trim() ? `书架里没有与 “${query}” 相关的书` : '书架还是空的,先导入书源或本地书籍'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="px-2 pb-1 pt-1 text-[10px] font-bold text-[var(--app-muted)]">
|
||||||
|
{query.trim() ? '书架匹配' : '书架'}
|
||||||
|
</p>
|
||||||
|
{matched.map((book) => (
|
||||||
|
<button
|
||||||
|
key={book.id}
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
openBook(book)
|
||||||
|
}}
|
||||||
|
onClick={() => openBook(book)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||||
|
>
|
||||||
|
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||||
|
{book.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={book.cover_url}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||||
|
<BookOpen size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||||
|
{book.name}
|
||||||
|
</p>
|
||||||
|
{book.is_local && (
|
||||||
|
<span className="shrink-0 rounded border border-[var(--app-border)] bg-[var(--app-panel-elevated)] px-1.5 py-0.5 text-[9px] text-[var(--app-muted)]">
|
||||||
|
本地
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--app-muted)]">
|
||||||
|
<span className="truncate">{book.author || '佚名'}</span>
|
||||||
|
{book.dur_chapter_title && <span className="truncate">读到 {book.dur_chapter_title}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
searchSources()
|
||||||
|
}}
|
||||||
|
onClick={searchSources}
|
||||||
|
disabled={!query.trim()}
|
||||||
|
className="mt-1 flex w-full items-center gap-2 rounded-xl border border-dashed border-[var(--app-border)] px-3 py-2 text-left text-xs font-bold text-brand-600 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Search size={13} />
|
||||||
|
{query.trim() ? `在书源中搜索「${query.trim()}」` : '输入关键词后可在书源中搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const SEARCH_PAGE_SIZE = 8
|
const SEARCH_PAGE_SIZE = 8
|
||||||
|
|
||||||
function LayoutHeaderSearch() {
|
function LayoutHeaderSearch() {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BookOpen, Clapperboard } from 'lucide-react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||||
|
|
||||||
|
// 顶栏的「影视 / 阅读」切换:只显示当前模式的图标(影视 / 阅读),点一下切到另一个模块。
|
||||||
|
// 模式只作用于首页(`/`),所以不在首页时顺带跳回首页,避免点了没反应。
|
||||||
|
export function LayoutReaderModeToggle() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||||
|
const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode)
|
||||||
|
|
||||||
|
const reading = homeMode === 'reading'
|
||||||
|
const label = reading ? '当前是阅读模式,点击切换到影视' : '当前是影视模式,点击切换到阅读'
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setHomeMode(reading ? 'media' : 'reading')
|
||||||
|
if (location.pathname !== '/') navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`shrink-0 rounded-xl border p-2.5 transition-colors ${
|
||||||
|
reading
|
||||||
|
? 'border-brand-500/60 bg-brand-500/10 text-brand-600 hover:bg-brand-500/20'
|
||||||
|
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{reading ? <BookOpen size={18} /> : <Clapperboard size={18} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
HomeLibraryRowSection,
|
HomeLibraryRowSection,
|
||||||
HomeLoadingState,
|
HomeLoadingState,
|
||||||
} from './HomePageSections'
|
} from './HomePageSections'
|
||||||
|
import { ReaderHomeContent } from './reader/ReaderHomeContent'
|
||||||
|
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||||
|
|
||||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export function HomePage() {
|
|||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
|
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||||
@@ -315,6 +318,14 @@ export function HomePage() {
|
|||||||
|
|
||||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||||
|
// 阅读模式下跳过影视内容的加载门槛,直接渲染书架。
|
||||||
|
if (homeMode === 'reading') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-16">
|
||||||
|
<ReaderHomeContent embedded />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||||
return <HomeLoadingState />
|
return <HomeLoadingState />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Check, ExternalLink, Loader2, RefreshCw, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderBrowserPage } from '../../api/reader'
|
||||||
|
|
||||||
|
// 书源页面的承载面板(对应 legado 的 BottomWebViewDialog)。
|
||||||
|
//
|
||||||
|
// 书源通过 java.startBrowser / java.startBrowserAwait 把页面交给宿主:
|
||||||
|
// - startBrowser / showBrowser:只展示,用户看完关掉即可;
|
||||||
|
// - startBrowserAwait:服务端会一直阻塞,直到这里的「√」把用户操作后的
|
||||||
|
// DOM 回传过去,书源再从 DOM 里解析出结果(光遇聚合的「切换线路」
|
||||||
|
// 就是从回传页面里抓 #serverValue 写进源变量)。
|
||||||
|
//
|
||||||
|
// iframe 刻意不带 allow-same-origin:页面是第三方 HTML,给它同源权限就能读写
|
||||||
|
// 本应用的 localStorage(JWT)。因此改由服务端注入的脚本用 postMessage 回传
|
||||||
|
// DOM,父窗口只负责发一个「请把当前 DOM 给我」的消息。
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
page: ReaderBrowserPage
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求 iframe 回传 DOM 的消息标识(与 browser_panel.go 注入的脚本一致)。 */
|
||||||
|
const DOM_REQUEST = '__mebox_dom__'
|
||||||
|
|
||||||
|
/** iframe 内页面的 fetch/XHR 代理消息(同浏览器 bridge 脚本)。 */
|
||||||
|
interface ProxyRequest {
|
||||||
|
__mebox_proxy__?: boolean
|
||||||
|
reqId?: string
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrowserPanel({ page, onClose }: Props) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
||||||
|
const awaitingRef = useRef(false)
|
||||||
|
const timerRef = useRef<number | undefined>(undefined)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [awaiting, setAwaiting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
// 提交回传结果:body 为用户操作后的页面 DOM(服务端作为 StrResponse.body())
|
||||||
|
const submit = useCallback(
|
||||||
|
async (html: string | null) => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await readerAPI.submitBrowserResult({
|
||||||
|
id: page.id,
|
||||||
|
body: html ?? '',
|
||||||
|
url: page.target_url ?? '',
|
||||||
|
cancelled: html === null,
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '提交失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page.id, page.target_url, onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 接收 iframe 的消息:① 回传 DOM;② 代发页面自己的接口请求
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (ev: MessageEvent) => {
|
||||||
|
const data = ev.data as ProxyRequest & { __mebox_dom__?: boolean; html?: string } | null
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
if (data.__mebox_dom__ === true) {
|
||||||
|
if (!awaitingRef.current) return
|
||||||
|
awaitingRef.current = false
|
||||||
|
setAwaiting(false)
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
void submit(data.html ?? '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.__mebox_proxy__ === true && data.reqId) {
|
||||||
|
const win = iframeRef.current?.contentWindow
|
||||||
|
const reply = (payload: Record<string, unknown>) => {
|
||||||
|
win?.postMessage({ __mebox_proxy_res__: true, reqId: data.reqId, ...payload }, '*')
|
||||||
|
}
|
||||||
|
void readerAPI
|
||||||
|
.browserXHR({
|
||||||
|
id: page.id,
|
||||||
|
url: data.url ?? '',
|
||||||
|
method: data.method ?? 'GET',
|
||||||
|
headers: data.headers ?? {},
|
||||||
|
body: data.body ?? '',
|
||||||
|
})
|
||||||
|
.then((res) => reply({ ...res }))
|
||||||
|
.catch((e) => {
|
||||||
|
// 让页面自己走失败分支,而不是一直挂着
|
||||||
|
reply({
|
||||||
|
status: 502,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
body: String((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '代理请求失败'),
|
||||||
|
base64: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('message', onMessage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', onMessage)
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [submit, page.id])
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const win = iframeRef.current?.contentWindow
|
||||||
|
if (!win) {
|
||||||
|
setError('页面尚未加载完成')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
awaitingRef.current = true
|
||||||
|
setAwaiting(true)
|
||||||
|
try {
|
||||||
|
win.postMessage(DOM_REQUEST, '*')
|
||||||
|
} catch {
|
||||||
|
awaitingRef.current = false
|
||||||
|
setAwaiting(false)
|
||||||
|
setError('无法读取页面内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 兜底:3 秒没回传就提示用户页面可能还没准备好
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
if (!awaitingRef.current) return
|
||||||
|
awaitingRef.current = false
|
||||||
|
setAwaiting(false)
|
||||||
|
setError('读取页面内容超时,请确认页面已加载完成后重试')
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalURL = page.target_url && !page.target_url.startsWith('data:') ? page.target_url : ''
|
||||||
|
const needConfirm = page.mode === 'wait'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex flex-col bg-black/60 p-0 sm:p-6"
|
||||||
|
// 面板叠在登录对话框之上,而登录对话框的遮罩是「点一下关闭」。
|
||||||
|
// 不拦住冒泡的话,点「完成」会把整个登录对话框一起关掉。
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-4xl flex-col overflow-hidden rounded-none border border-[var(--app-border)] bg-[var(--app-panel)] sm:rounded-2xl">
|
||||||
|
{/* 顶栏:标题 + 外部打开 + 关闭 */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-[var(--app-border)] px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--app-text)]">
|
||||||
|
{page.title || '书源页面'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||||
|
{page.mode === 'wait' ? '操作完成后点右下角「完成」把结果回传给书源' : '确认完毕后关闭即可'}
|
||||||
|
{page.target_url ? ` · ${page.target_url}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!loaded && <Loader2 size={15} className="animate-spin text-[var(--app-muted)]" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="重新加载"
|
||||||
|
onClick={() => {
|
||||||
|
setLoaded(false)
|
||||||
|
if (iframeRef.current) iframeRef.current.src = page.page_url
|
||||||
|
}}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
</button>
|
||||||
|
{externalURL && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="在新标签打开原始地址(浏览器里没有书源登录态)"
|
||||||
|
onClick={() => window.open(externalURL, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-xs text-red-500">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 页面本体:sandbox 不含 allow-same-origin,避免第三方页面拿到本应用权限 */}
|
||||||
|
<div className="min-h-0 flex-1 bg-white">
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={page.title || '书源页面'}
|
||||||
|
src={page.page_url}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
sandbox="allow-scripts allow-forms allow-popups allow-modals"
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部动作 */}
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--app-border)] px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submit(null)}
|
||||||
|
className="btn-outline text-xs text-red-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={13} className="mr-1 inline" /> 取消
|
||||||
|
</button>
|
||||||
|
{needConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || awaiting}
|
||||||
|
onClick={confirm}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{awaiting ? (
|
||||||
|
<Loader2 size={13} className="mr-1 inline animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check size={13} className="mr-1 inline" />
|
||||||
|
)}
|
||||||
|
完成(回传页面)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Gauge,
|
||||||
|
ListMusic,
|
||||||
|
Loader2,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Scissors,
|
||||||
|
Timer,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Virtuoso } from 'react-virtuoso'
|
||||||
|
|
||||||
|
import type { ReaderChapter } from '../../api/reader'
|
||||||
|
import {
|
||||||
|
AUDIO_CREDITS_MAX,
|
||||||
|
AUDIO_SPEEDS,
|
||||||
|
AUDIO_TIMERS,
|
||||||
|
useReaderSettingsStore,
|
||||||
|
} from '../../stores/readerSettings'
|
||||||
|
|
||||||
|
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||||
|
// hls.js 播 m3u8,<audio> 播直链。
|
||||||
|
// transport:上一章 | -15s | 播放暂停 | +15s | 下一章;
|
||||||
|
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||||
|
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||||
|
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||||
|
|
||||||
|
interface ReaderAudioPanelProps {
|
||||||
|
src: string
|
||||||
|
title: string
|
||||||
|
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
|
||||||
|
cover: string
|
||||||
|
theme: { bg: string; text: string; accent: string }
|
||||||
|
initialPos: number // 恢复进度(秒)
|
||||||
|
/** 片头跳过秒数(Book.openCredits)。 */
|
||||||
|
openCredits: number
|
||||||
|
/** 片尾跳过秒数(Book.closeCredits)。 */
|
||||||
|
closeCredits: number
|
||||||
|
chapters: ReaderChapter[]
|
||||||
|
chapterIndex: number | null
|
||||||
|
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
|
||||||
|
hasNext: boolean
|
||||||
|
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||||
|
transcoding?: boolean
|
||||||
|
onProgress: (seconds: number) => void
|
||||||
|
onPrevChapter: () => void
|
||||||
|
onNextChapter: () => void
|
||||||
|
onSelectChapter: (index: number) => void
|
||||||
|
onEnded: () => void
|
||||||
|
onCreditsChange: (open: number, close: number) => void
|
||||||
|
onToggleMenu: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -15s / +15s 步长(legado AudioPlayActivity.SEEK_STEP)。 */
|
||||||
|
const SEEK_STEP = 15
|
||||||
|
/** 片头片尾滑杆步进(秒)。 */
|
||||||
|
const CREDIT_STEP = 5
|
||||||
|
/** 动作行高度 76px:抽屉与遮罩都从动作行上沿开始,保证动作行常驻可点。 */
|
||||||
|
const ACTION_BAR_BOTTOM = 'bottom-[76px]'
|
||||||
|
|
||||||
|
type Sheet = 'none' | 'chapters' | 'timer' | 'speed' | 'credits'
|
||||||
|
|
||||||
|
function fmt(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) return '0:00'
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCountdown(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderAudioPanel({
|
||||||
|
src,
|
||||||
|
title,
|
||||||
|
cover,
|
||||||
|
theme,
|
||||||
|
initialPos,
|
||||||
|
openCredits,
|
||||||
|
closeCredits,
|
||||||
|
chapters,
|
||||||
|
chapterIndex,
|
||||||
|
hasNext,
|
||||||
|
transcoding = false,
|
||||||
|
onProgress,
|
||||||
|
onPrevChapter,
|
||||||
|
onNextChapter,
|
||||||
|
onSelectChapter,
|
||||||
|
onEnded,
|
||||||
|
onCreditsChange,
|
||||||
|
onToggleMenu,
|
||||||
|
}: ReaderAudioPanelProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||||
|
const restoredRef = useRef(false)
|
||||||
|
const skippedEndRef = useRef(false)
|
||||||
|
|
||||||
|
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||||
|
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||||
|
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||||
|
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [cur, setCur] = useState(0)
|
||||||
|
const [dur, setDur] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||||
|
const [audioError, setAudioError] = useState('')
|
||||||
|
const [sheet, setSheet] = useState<Sheet>('none')
|
||||||
|
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||||
|
const [coverOK, setCoverOK] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
setCoverOK(true)
|
||||||
|
}, [cover])
|
||||||
|
|
||||||
|
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||||
|
const [timerLeft, setTimerLeft] = useState(0)
|
||||||
|
const [timerActive, setTimerActive] = useState(false)
|
||||||
|
const [timerPreset, setTimerPreset] = useState(defaultTimer)
|
||||||
|
const timerDeadlineRef = useRef(0)
|
||||||
|
|
||||||
|
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
|
||||||
|
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
|
||||||
|
useEffect(() => {
|
||||||
|
setCreditDraft({ open: openCredits, close: closeCredits })
|
||||||
|
}, [openCredits, closeCredits])
|
||||||
|
|
||||||
|
// ── 换源/换章:重建播放器 ──
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
restoredRef.current = false
|
||||||
|
skippedEndRef.current = false
|
||||||
|
setLoading(true)
|
||||||
|
setAudioError('')
|
||||||
|
setCur(0)
|
||||||
|
setDur(0)
|
||||||
|
let cancelled = false
|
||||||
|
let hls: { destroy: () => void } | null = null
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
if (src.includes('.m3u8')) {
|
||||||
|
const mod = await import('hls.js')
|
||||||
|
const Hls = mod.default
|
||||||
|
if (cancelled) return
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const inst = new Hls({ enableWorker: true })
|
||||||
|
inst.loadSource(src)
|
||||||
|
inst.attachMedia(audio)
|
||||||
|
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||||
|
if (data.fatal) setLoading(false)
|
||||||
|
})
|
||||||
|
hls = inst
|
||||||
|
hlsRef.current = inst
|
||||||
|
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
audio.src = src // Safari 原生 HLS
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audio.src = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void setup()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
hls?.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
audio.load()
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (audio) audio.playbackRate = speed
|
||||||
|
}, [speed])
|
||||||
|
|
||||||
|
// ── 定时关闭 ──
|
||||||
|
const applyTimer = useCallback(
|
||||||
|
(minutes: number) => {
|
||||||
|
setDefaultTimer(minutes)
|
||||||
|
setTimerPreset(minutes)
|
||||||
|
if (minutes <= 0) {
|
||||||
|
timerDeadlineRef.current = 0
|
||||||
|
setTimerLeft(0)
|
||||||
|
setTimerActive(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timerDeadlineRef.current = Date.now() + minutes * 60_000
|
||||||
|
setTimerLeft(minutes * 60)
|
||||||
|
setTimerActive(true)
|
||||||
|
},
|
||||||
|
[setDefaultTimer],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||||
|
const timerBootRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerBootRef.current) return
|
||||||
|
timerBootRef.current = true
|
||||||
|
if (defaultTimer > 0) applyTimer(defaultTimer)
|
||||||
|
}, [applyTimer, defaultTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timerActive) return
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
|
||||||
|
if (audio && audio.paused) {
|
||||||
|
timerDeadlineRef.current += 1000
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
|
||||||
|
setTimerLeft(left)
|
||||||
|
if (left <= 0) {
|
||||||
|
timerDeadlineRef.current = 0
|
||||||
|
setTimerActive(false)
|
||||||
|
audio?.pause()
|
||||||
|
toast('定时结束,已暂停播放')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [timerActive])
|
||||||
|
|
||||||
|
// ── 播放控制 ──
|
||||||
|
const toggle = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
if (audio.paused) void audio.play().catch(() => undefined)
|
||||||
|
else audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekBy = useCallback((delta: number) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
|
||||||
|
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
|
||||||
|
audio.currentTime = next
|
||||||
|
setCur(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const seekTo = useCallback((seconds: number) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.currentTime = seconds
|
||||||
|
setCur(seconds)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeSpeed = (v: number) => {
|
||||||
|
setSpeed(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCredits = (open: number, close: number) => {
|
||||||
|
setCreditDraft({ open, close })
|
||||||
|
onCreditsChange(open, close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||||
|
|
||||||
|
const chipClass = (active: boolean) =>
|
||||||
|
`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${active ? '' : 'opacity-70'}`
|
||||||
|
|
||||||
|
const chipStyle = (active: boolean) => ({
|
||||||
|
borderColor: active ? theme.accent : theme.text + '33',
|
||||||
|
color: active ? theme.accent : theme.text,
|
||||||
|
backgroundColor: active ? theme.accent + '1a' : 'transparent',
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeSheet = () => setSheet('none')
|
||||||
|
|
||||||
|
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
|
||||||
|
|
||||||
|
const chapterList = useMemo(
|
||||||
|
() => chapters.map((c, i) => ({ ...c, i })),
|
||||||
|
[chapters],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex h-full flex-col overflow-hidden"
|
||||||
|
onClick={() => {
|
||||||
|
if (sheet !== 'none') closeSheet()
|
||||||
|
else onToggleMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
|
||||||
|
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
|
||||||
|
{cover && coverOK && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="pointer-events-none absolute inset-0 z-0 h-full w-full scale-110 object-cover"
|
||||||
|
style={{ filter: 'blur(32px)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
|
style={{ backgroundColor: theme.bg, opacity: 0.3 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 播放信息 + transport(抽屉打开时被抽屉压住) */}
|
||||||
|
<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-5 px-6">
|
||||||
|
{/* 封面(legado ivCover:圆形封面图;无封面/加载失败退回主题色圆点) */}
|
||||||
|
<div
|
||||||
|
className="relative flex h-32 w-32 items-center justify-center rounded-full border-2"
|
||||||
|
style={{ borderColor: theme.accent + '66', color: theme.accent }}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-full">
|
||||||
|
{cover && coverOK ? (
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt={title}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setCoverOK(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full" style={{ backgroundColor: theme.accent + '33' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`pointer-events-none absolute inset-0 rounded-full ${playing ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ boxShadow: `0 0 0 6px ${theme.accent}22` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-full px-4 text-center">
|
||||||
|
<p className="truncate text-sm font-bold" style={{ color: theme.text }}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
|
||||||
|
{audioError ? (
|
||||||
|
<span className="font-bold" style={{ color: theme.accent }}>
|
||||||
|
{audioError}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
|
||||||
|
倍速 {speed}x
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!audioError && timerActive ? ` · 定时 ${fmtCountdown(timerLeft)}` : ''}
|
||||||
|
{!audioError && (openCredits > 0 || closeCredits > 0)
|
||||||
|
? ` · 跳过片头${openCredits}s/片尾${closeCredits}s`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="flex w-full max-w-md items-center gap-2" style={{ color: theme.text }}>
|
||||||
|
<span className="w-10 text-right text-2xs tabular-nums opacity-70">{fmt(cur)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(1, Math.floor(dur))}
|
||||||
|
value={Math.floor(cur)}
|
||||||
|
onChange={(e) => seekTo(Number(e.target.value))}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-1"
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-2xs tabular-nums opacity-70">{fmt(dur)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-5"
|
||||||
|
style={{ color: theme.text }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrevChapter}
|
||||||
|
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||||
|
aria-label="上一章"
|
||||||
|
title="上一章"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => seekBy(-SEEK_STEP)}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
|
||||||
|
style={{ borderColor: theme.text + '44' }}
|
||||||
|
aria-label={`后退 ${SEEK_STEP} 秒`}
|
||||||
|
title={`后退 ${SEEK_STEP} 秒`}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={12} />
|
||||||
|
<span className="text-[10px]">{SEEK_STEP}s</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg"
|
||||||
|
style={{ backgroundColor: theme.accent }}
|
||||||
|
aria-label={playing ? '暂停' : '播放'}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={22} className="animate-spin" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause size={22} />
|
||||||
|
) : (
|
||||||
|
<Play size={22} className="ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => seekBy(SEEK_STEP)}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
|
||||||
|
style={{ borderColor: theme.text + '44' }}
|
||||||
|
aria-label={`前进 ${SEEK_STEP} 秒`}
|
||||||
|
title={`前进 ${SEEK_STEP} 秒`}
|
||||||
|
>
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
<span className="text-[10px]">{SEEK_STEP}s</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNextChapter}
|
||||||
|
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||||
|
aria-label="下一章"
|
||||||
|
title="下一章"
|
||||||
|
>
|
||||||
|
<ChevronRight size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动作行:常驻底部,抽屉打开时仍可点(可在几个抽屉间直接切换) */}
|
||||||
|
<div
|
||||||
|
className="relative z-50 grid h-[76px] w-full shrink-0 grid-cols-4 px-6"
|
||||||
|
style={{ color: theme.text }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
icon: <ListMusic size={18} />,
|
||||||
|
label: '章节',
|
||||||
|
active: sheet === 'chapters',
|
||||||
|
action: () => openSheet('chapters'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Timer size={18} />,
|
||||||
|
label: timerActive ? fmtCountdown(timerLeft) : '定时',
|
||||||
|
active: timerActive || sheet === 'timer',
|
||||||
|
action: () => openSheet('timer'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Gauge size={18} />,
|
||||||
|
label: `${speed}x`,
|
||||||
|
active: speed !== 1 || sheet === 'speed',
|
||||||
|
action: () => openSheet('speed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Scissors size={18} />,
|
||||||
|
label: '片头片尾',
|
||||||
|
active: openCredits > 0 || closeCredits > 0 || sheet === 'credits',
|
||||||
|
action: () => openSheet('credits'),
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
onClick={item.action}
|
||||||
|
className="flex flex-col items-center justify-center gap-1 opacity-80 hover:opacity-100"
|
||||||
|
style={item.active ? { color: theme.accent, opacity: 1 } : undefined}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="text-2xs tabular-nums">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 底部抽屉(到动作行上沿为止,动作行始终可点) ── */}
|
||||||
|
{sheet !== 'none' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭面板"
|
||||||
|
className={`absolute inset-x-0 top-0 z-30 cursor-default bg-black/30 ${ACTION_BAR_BOTTOM}`}
|
||||||
|
onClick={closeSheet}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 z-40 max-h-[65%] overflow-hidden rounded-t-2xl border-t ${ACTION_BAR_BOTTOM}`}
|
||||||
|
style={sheetStyle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{sheet === 'chapters' && (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
|
||||||
|
style={{ borderColor: theme.text + '22' }}
|
||||||
|
>
|
||||||
|
<span>章节({chapters.length})</span>
|
||||||
|
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[45vh] min-h-0">
|
||||||
|
<Virtuoso
|
||||||
|
data={chapterList}
|
||||||
|
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||||
|
itemContent={(_i, row) => {
|
||||||
|
const isCurrent = row.i === chapterIndex
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (row.is_volume) return
|
||||||
|
closeSheet()
|
||||||
|
onSelectChapter(row.i)
|
||||||
|
}}
|
||||||
|
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||||
|
row.is_volume ? 'font-bold opacity-70' : ''
|
||||||
|
}`}
|
||||||
|
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sheet === 'timer' && (
|
||||||
|
<div className="px-4 pb-6 pt-4">
|
||||||
|
<p className="mb-3 text-xs font-bold">定时关闭</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AUDIO_TIMERS.map((m) => {
|
||||||
|
const active = timerActive ? timerPreset === m : m === 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTimer(m)}
|
||||||
|
className={chipClass(active)}
|
||||||
|
style={chipStyle(active)}
|
||||||
|
>
|
||||||
|
{m === 0 ? '关闭定时' : `${m} 分钟`}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xs opacity-60">
|
||||||
|
{timerActive ? `剩余 ${fmtCountdown(timerLeft)},播完自动暂停` : '未开启:不会自动停止播放'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sheet === 'speed' && (
|
||||||
|
<div className="px-4 pb-6 pt-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs font-bold">
|
||||||
|
<span>播放倍速</span>
|
||||||
|
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||||
|
{speed}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
value={speed}
|
||||||
|
onChange={(e) => changeSpeed(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{AUDIO_SPEEDS.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeSpeed(v)}
|
||||||
|
className={chipClass(v === speed)}
|
||||||
|
style={chipStyle(v === speed)}
|
||||||
|
>
|
||||||
|
{v}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sheet === 'credits' && (
|
||||||
|
<div className="px-4 pb-6 pt-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs font-bold">
|
||||||
|
<span>跳过片头片尾</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveCredits(0, 0)}
|
||||||
|
className="text-2xs opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-2xs">
|
||||||
|
<span className="opacity-70">片头</span>
|
||||||
|
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||||
|
{creditDraft.open} 秒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={AUDIO_CREDITS_MAX}
|
||||||
|
step={CREDIT_STEP}
|
||||||
|
value={creditDraft.open}
|
||||||
|
onChange={(e) => setCreditDraft((d) => ({ ...d, open: Number(e.target.value) }))}
|
||||||
|
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||||
|
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||||
|
className="w-full"
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-2xs">
|
||||||
|
<span className="opacity-70">片尾</span>
|
||||||
|
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||||
|
{creditDraft.close} 秒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={AUDIO_CREDITS_MAX}
|
||||||
|
step={CREDIT_STEP}
|
||||||
|
value={creditDraft.close}
|
||||||
|
onChange={(e) => setCreditDraft((d) => ({ ...d, close: Number(e.target.value) }))}
|
||||||
|
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||||
|
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||||
|
className="w-full"
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{[0, 15, 30, 45, 60, 90].map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveCredits(v, creditDraft.close)}
|
||||||
|
className={chipClass(v === creditDraft.open)}
|
||||||
|
style={chipStyle(v === creditDraft.open)}
|
||||||
|
>
|
||||||
|
片头 {v}s
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xs opacity-60">
|
||||||
|
每章从头播放时跳过前 {creditDraft.open} 秒;剩最后 {creditDraft.close} 秒时视为本章播完。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
className="hidden"
|
||||||
|
preload="metadata"
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onError={(e) => {
|
||||||
|
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||||
|
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||||
|
const code = e.currentTarget.error?.code
|
||||||
|
setLoading(false)
|
||||||
|
setAudioError(
|
||||||
|
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||||
|
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||||
|
: code === MediaError.MEDIA_ERR_NETWORK
|
||||||
|
? '音频加载失败,请检查网络或源文件是否还在'
|
||||||
|
: '音频无法播放',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const audio = e.currentTarget
|
||||||
|
setDur(audio.duration)
|
||||||
|
setLoading(false)
|
||||||
|
audio.playbackRate = speed
|
||||||
|
if (!restoredRef.current) {
|
||||||
|
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||||
|
if (initialPos > 0 && (total === 0 || initialPos < total)) {
|
||||||
|
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||||
|
audio.currentTime = initialPos
|
||||||
|
setCur(initialPos)
|
||||||
|
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||||
|
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||||
|
audio.currentTime = openCredits
|
||||||
|
setCur(openCredits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restoredRef.current = true
|
||||||
|
void audio.play().catch(() => undefined)
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => {
|
||||||
|
const audio = e.currentTarget
|
||||||
|
const t = audio.currentTime
|
||||||
|
setCur(t)
|
||||||
|
onProgress(t)
|
||||||
|
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||||
|
if (
|
||||||
|
closeCredits > 0 &&
|
||||||
|
!skippedEndRef.current &&
|
||||||
|
Number.isFinite(audio.duration) &&
|
||||||
|
audio.duration > closeCredits + 1 &&
|
||||||
|
t >= audio.duration - closeCredits
|
||||||
|
) {
|
||||||
|
skippedEndRef.current = true
|
||||||
|
if (!hasNext) {
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEnded()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEnded={onEnded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader'
|
||||||
|
|
||||||
|
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
|
||||||
|
|
||||||
|
export default function ReaderBookPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const sourceURL = params.get('source_url') ?? ''
|
||||||
|
const bookURL = params.get('book_url') ?? ''
|
||||||
|
const [qName, qAuthor] = [params.get('name') ?? '', params.get('author') ?? '']
|
||||||
|
const [qCover] = [params.get('cover_url') ?? '']
|
||||||
|
const [originID] = [params.get('origin_id') ?? '']
|
||||||
|
const [originName] = [params.get('origin_name') ?? '']
|
||||||
|
|
||||||
|
const [info, setInfo] = useState<ReaderBookInfo | null>(null)
|
||||||
|
const [infoError, setInfoError] = useState('')
|
||||||
|
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
|
||||||
|
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [tocExpanded, setTocExpanded] = useState(false)
|
||||||
|
const [introExpanded, setIntroExpanded] = useState(false)
|
||||||
|
|
||||||
|
const loadInfo = () => {
|
||||||
|
if (!bookURL) return
|
||||||
|
setInfoError('')
|
||||||
|
readerAPI
|
||||||
|
.bookInfo({ source_url: sourceURL, book_url: bookURL })
|
||||||
|
.then(setInfo)
|
||||||
|
.catch((e) => setInfoError(e?.response?.data?.error ?? '详情加载失败'))
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(loadInfo, [bookURL])
|
||||||
|
|
||||||
|
// 书架状态 + 目录
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookURL) return
|
||||||
|
readerAPI
|
||||||
|
.listBooks()
|
||||||
|
.then((books) => {
|
||||||
|
const hit = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
|
||||||
|
setShelfBook(hit)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
readerAPI
|
||||||
|
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
|
||||||
|
.then(setChapters)
|
||||||
|
.catch(() => setChapters([]))
|
||||||
|
}, [bookURL, sourceURL])
|
||||||
|
|
||||||
|
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
|
||||||
|
useEffect(() => {
|
||||||
|
const tocURL = info?.toc_url
|
||||||
|
if (!tocURL || tocURL === bookURL) return
|
||||||
|
readerAPI
|
||||||
|
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL })
|
||||||
|
.then(setChapters)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [info?.toc_url, sourceURL, bookURL])
|
||||||
|
|
||||||
|
const name = info?.name || qName
|
||||||
|
const author = info?.author || qAuthor
|
||||||
|
const cover = info?.cover_url || qCover
|
||||||
|
|
||||||
|
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
|
||||||
|
|
||||||
|
const startReading = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
let book = shelfBook
|
||||||
|
if (!book) {
|
||||||
|
book = await readerAPI.addBook({
|
||||||
|
origin: {
|
||||||
|
source_id: originID,
|
||||||
|
origin: sourceURL,
|
||||||
|
origin_name: originName || info?.name || '',
|
||||||
|
origin_type: 0,
|
||||||
|
book_url: bookURL,
|
||||||
|
latest_chapter: info?.latest_chapter ?? '',
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
author,
|
||||||
|
cover_url: cover,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
navigate(`/reader/view/${book.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromShelf = async () => {
|
||||||
|
if (!shelfBook) return
|
||||||
|
try {
|
||||||
|
await readerAPI.removeBook(shelfBook.id)
|
||||||
|
toast.success('已移出书架')
|
||||||
|
setShelfBook(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('移出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToc = async () => {
|
||||||
|
if (!info?.toc_url && !bookURL) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL })
|
||||||
|
setChapters(list)
|
||||||
|
toast.success(`目录已刷新,共 ${list.length} 章`)
|
||||||
|
} catch {
|
||||||
|
toast.error('目录刷新失败')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookURL) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto px-6 py-24 text-center text-sm text-[var(--app-muted)]">缺少书籍参数</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<p className="flex-1 truncate text-sm font-bold text-[var(--app-text)]">{name}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refreshToc}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
title="刷新目录"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={busy ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
|
||||||
|
<div className="mt-6 flex gap-5">
|
||||||
|
<div className="h-40 w-28 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} alt={name} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<BookOpen size={24} className="text-[var(--app-muted)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="font-display text-xl text-ink-600">{name || '未知书名'}</h1>
|
||||||
|
<p className="mt-1 text-xs text-[var(--app-muted)]">{author || '佚名'}</p>
|
||||||
|
{info?.kind && (
|
||||||
|
<p className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{info.kind.split(/[,,]/).filter(Boolean).slice(0, 5).map((k) => (
|
||||||
|
<span key={k} className="rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||||||
|
{k}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
|
||||||
|
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || '加载中…'}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">书源:{originName || sourceURL}</p>
|
||||||
|
{(info?.intro || infoError) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className={`text-xs leading-5 text-[var(--app-subtle)] ${introExpanded ? '' : 'line-clamp-3'}`}>
|
||||||
|
{infoError || info?.intro}
|
||||||
|
</p>
|
||||||
|
{!infoError && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIntroExpanded((v) => !v)}
|
||||||
|
className="mt-1 inline-flex items-center text-2xs text-brand-600"
|
||||||
|
>
|
||||||
|
{introExpanded ? <>收起 <ChevronUp size={11} /></> : <>展开 <ChevronDown size={11} /></>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动作区 */}
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
{shelfBook ? (
|
||||||
|
<button type="button" onClick={removeFromShelf} className="btn-outline flex-1 text-xs">
|
||||||
|
<Trash2 size={13} className="mr-1 inline" /> 移出书架
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
|
||||||
|
<BookOpen size={13} className="mr-1 inline" /> 加入书架
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
|
||||||
|
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
|
||||||
|
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 目录 */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-2">
|
||||||
|
<p className="text-sm font-bold text-[var(--app-text)]">
|
||||||
|
目录 {chapters !== null && <span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>}
|
||||||
|
</p>
|
||||||
|
{chapters !== null && chapters.length > 20 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTocExpanded((v) => !v)}
|
||||||
|
className="text-xs text-brand-600"
|
||||||
|
>
|
||||||
|
{tocExpanded ? '收起' : '展开全部'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{chapters === null ? (
|
||||||
|
<div className="flex justify-center py-10 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`mt-2 overflow-y-auto ${tocExpanded ? 'max-h-[70vh]' : 'max-h-80'}`}>
|
||||||
|
{chapters.map((c) => (
|
||||||
|
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
|
||||||
|
{c.is_volume ? (
|
||||||
|
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
|
||||||
|
) : (
|
||||||
|
c.title
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||||
|
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||||
|
|
||||||
|
interface ReaderComicProps {
|
||||||
|
images: string[]
|
||||||
|
theme: { bg: string; text: string; accent: string }
|
||||||
|
mode: 'page' | 'scroll'
|
||||||
|
page: number // 单页模式当前页(0 基)
|
||||||
|
onZone: (zone: 'left' | 'center' | 'right') => void
|
||||||
|
initialImage: number
|
||||||
|
onProgress: (imageIndex: number) => void
|
||||||
|
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||||
|
onScrolled: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) {
|
||||||
|
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
{state === 'loading' && (
|
||||||
|
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
|
||||||
|
<Loader2 className="animate-spin opacity-50" size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === 'error' ? (
|
||||||
|
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
|
||||||
|
图片加载失败
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
loading="lazy"
|
||||||
|
alt=""
|
||||||
|
onLoad={() => setState('ok')}
|
||||||
|
onError={() => setState('error')}
|
||||||
|
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
|
||||||
|
style={{ backgroundColor: theme.bg }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
const restoredRef = useRef(false)
|
||||||
|
|
||||||
|
// 滚动模式:恢复进度(图片序号)并上报当前图
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'scroll') return
|
||||||
|
if (!restoredRef.current && initialImage > 0 && imgRefs.current[initialImage]) {
|
||||||
|
imgRefs.current[initialImage]?.scrollIntoView({ block: 'start' })
|
||||||
|
}
|
||||||
|
restoredRef.current = true
|
||||||
|
}, [mode, initialImage, images.length])
|
||||||
|
|
||||||
|
// 外部要求滚动到指定图片(菜单进度条)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'scroll' || scrollTo === null) return
|
||||||
|
imgRefs.current[scrollTo]?.scrollIntoView({ block: 'start' })
|
||||||
|
onScrolled()
|
||||||
|
}, [scrollTo, mode, onScrolled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'page') return
|
||||||
|
restoredRef.current = false
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||||
|
本章没有图片
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'page') {
|
||||||
|
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
|
||||||
|
const idx = Math.min(Math.max(page, 0), images.length - 1)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上下滚动模式
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
onScroll={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
// 以视口顶部所在图片为当前进度
|
||||||
|
let current = 0
|
||||||
|
for (let i = 0; i < imgRefs.current.length; i++) {
|
||||||
|
const node = imgRefs.current[i]
|
||||||
|
if (node && node.offsetTop <= el.scrollTop + el.clientHeight * 0.4) current = i
|
||||||
|
}
|
||||||
|
onProgress(current)
|
||||||
|
if (!restoredRef.current && initialImage >= 0) {
|
||||||
|
restoredRef.current = true
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(node) => {
|
||||||
|
imgRefs.current[i] = node
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = (e.clientX - rect.left) / rect.width
|
||||||
|
if (x < 0.3) {
|
||||||
|
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
|
} else if (x > 0.7) {
|
||||||
|
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
|
} else {
|
||||||
|
onZone('center')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComicImage src={src} theme={theme} />
|
||||||
|
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
|
||||||
|
{i + 1} / {images.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="h-10" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderBook } from '../../api/reader'
|
||||||
|
import { confirmAction } from '../../components/confirmAction'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { ReaderModeSwitch } from './ReaderModeSwitch'
|
||||||
|
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
|
||||||
|
|
||||||
|
// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。
|
||||||
|
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。
|
||||||
|
|
||||||
|
// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。
|
||||||
|
// total_chapter_num 为 0 表示目录尚未缓存,无法计算。
|
||||||
|
function unreadChapters(book: ReaderBook): number | null {
|
||||||
|
if (book.total_chapter_num <= 0) return null
|
||||||
|
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||||
|
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
|
const [books, setBooks] = useState<ReaderBook[] | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
|
||||||
|
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 下拉菜单:点外部或按 Esc 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
const onPointerDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||||
|
}
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setMenuOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onPointerDown)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onPointerDown)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setError('')
|
||||||
|
readerAPI
|
||||||
|
.listBooks()
|
||||||
|
.then(setBooks)
|
||||||
|
.catch((e) => setError(e?.response?.data?.error ?? '加载书架失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setUploading(0)
|
||||||
|
try {
|
||||||
|
const book = await readerAPI.uploadLocalBook(file, setUploading)
|
||||||
|
toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`)
|
||||||
|
load()
|
||||||
|
navigate(`/reader/view/${book.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(null)
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移出书架:所有书籍都可移除。本地导入的书会连同服务器文件一起删除,
|
||||||
|
// 原地引用的只解除引用,书源书籍只清掉书架记录与阅读进度。
|
||||||
|
const removeFromShelf = async (book: ReaderBook) => {
|
||||||
|
const message = book.local_external
|
||||||
|
? `《${book.name}》是原地引用服务器上的文件,移出书架只解除引用,不会删除源文件。确定吗?`
|
||||||
|
: book.is_local
|
||||||
|
? `《${book.name}》及其在服务器上的文件都会被删除,确定吗?`
|
||||||
|
: `《${book.name}》会连同阅读进度一起从书架移除,不影响书源。确定吗?`
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: '移出书架',
|
||||||
|
message,
|
||||||
|
confirmText: '移出',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await readerAPI.removeBook(book.id)
|
||||||
|
setBooks((prev) => (prev ? prev.filter((b) => b.id !== book.id) : prev))
|
||||||
|
toast.success('已移出书架')
|
||||||
|
} catch {
|
||||||
|
toast.error('移出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPicker = async (path: string) => {
|
||||||
|
const kind = picker
|
||||||
|
setPicker(null)
|
||||||
|
try {
|
||||||
|
const book =
|
||||||
|
kind === 'audio'
|
||||||
|
? await readerAPI.importLocalAudioDir(path)
|
||||||
|
: await readerAPI.importLocalBookFromPath(path)
|
||||||
|
toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`)
|
||||||
|
load()
|
||||||
|
navigate(`/reader/view/${book.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!embedded && <ReaderModeSwitch />}
|
||||||
|
|
||||||
|
{/* 标题右侧一个下拉入口,收纳全部书架操作 */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h1 className="shrink-0 font-display text-2xl text-ink-600">书架</h1>
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.epub,text/plain,application/epub+zip"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) void upload(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||||
|
title="书架操作"
|
||||||
|
>
|
||||||
|
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
|
||||||
|
{uploading !== null ? `上传中 ${uploading}%` : '管理'}
|
||||||
|
<ChevronDown size={13} className={`transition-transform ${menuOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 z-30 mt-1.5 w-40 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
title="上传 TXT / EPUB 到服务器阅读"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
fileRef.current?.click()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<FileUp size={13} /> 本地导入
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
title="选择服务器上已有的 TXT / EPUB 文件导入(原地引用,不复制)"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setPicker('book')
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<FolderOpen size={13} /> 服务器导入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
title="选择服务器上的一个目录导入为有声书(含 .strm 播放指针)"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setPicker('audio')
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Headphones size={13} /> 有声书导入
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||||
|
<Link
|
||||||
|
to="/reader/sources"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<Settings2 size={13} /> 书源管理
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{books === null && !error && (
|
||||||
|
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && books === null && (
|
||||||
|
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-8 text-center">
|
||||||
|
<p className="text-sm text-[var(--app-muted)]">{error}</p>
|
||||||
|
<button type="button" onClick={load} className="mt-4 inline-flex items-center gap-1.5 btn-outline text-xs">
|
||||||
|
<RefreshCw size={13} /> 重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{books !== null && books.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||||
|
<BookOpen size={20} className="text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-bold text-[var(--app-text)]">书架空空如也</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--app-muted)]">上传本地 TXT / EPUB,或导入书源后搜索加入书架</p>
|
||||||
|
<div className="mt-5 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="btn-outline text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{uploading !== null ? `上传中 ${uploading}%` : '上传本地书籍'}
|
||||||
|
</button>
|
||||||
|
<Link to="/reader/sources" className="btn-outline text-xs">导入书源</Link>
|
||||||
|
<Link to="/reader/search" className="btn-primary text-xs">去搜索</Link>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<button type="button" onClick={() => setPicker('book')} className="btn-outline whitespace-nowrap text-xs">
|
||||||
|
从服务器导入
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setPicker('audio')} className="btn-outline whitespace-nowrap text-xs">
|
||||||
|
从服务器导入有声书
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{books !== null && books.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-6 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||||
|
{books.map((book) => {
|
||||||
|
const unread = unreadChapters(book)
|
||||||
|
return (
|
||||||
|
<div key={book.id} className="group">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate(`/reader/view/${book.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
navigate(`/reader/view/${book.id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
<div className="aspect-[3/4] w-full">
|
||||||
|
{book.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={book.cover_url}
|
||||||
|
alt={book.name}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<BookOpen size={22} className="text-[var(--app-muted)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
|
||||||
|
{book.is_local && (
|
||||||
|
<span className="absolute bottom-1 left-1 flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||||
|
<HardDrive size={10} /> 本地
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{unread !== null && (
|
||||||
|
<span
|
||||||
|
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||||
|
className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${
|
||||||
|
unread > 0 ? 'bg-rose-500/90' : 'bg-black/55'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`移出书架:${book.name}`}
|
||||||
|
title="移出书架"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void removeFromShelf(book)
|
||||||
|
}}
|
||||||
|
className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
|
||||||
|
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||||
|
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{picker && (
|
||||||
|
<ServerFilePickerDialog
|
||||||
|
mode={picker === 'audio' ? 'dir' : 'file'}
|
||||||
|
extensions={picker === 'audio' ? undefined : ['.txt', '.epub']}
|
||||||
|
title={picker === 'audio' ? '选择有声书目录(音频文件与 .strm)' : '选择服务器上的书籍文件(TXT / EPUB)'}
|
||||||
|
onSelect={(path) => void runPicker(path)}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ReaderHomeContent } from './ReaderHomeContent'
|
||||||
|
|
||||||
|
// 阅读主页(/reader):书架 + 搜索/书源入口。
|
||||||
|
export default function ReaderHomePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[100dvh] w-full max-w-7xl px-4 pb-16 pt-4 sm:px-6">
|
||||||
|
<ReaderHomeContent />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BookOpen, Clapperboard } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
|
|
||||||
|
// 首页「影视 / 阅读」分段切换(样式对齐 LibraryTagBar 页签)。
|
||||||
|
export function ReaderModeSwitch() {
|
||||||
|
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||||
|
const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode)
|
||||||
|
|
||||||
|
const itemClass = (active: boolean) =>
|
||||||
|
`flex items-center gap-1.5 rounded-xl border px-4 py-1.5 text-xs font-bold transition ${
|
||||||
|
active
|
||||||
|
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||||
|
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:text-[var(--app-text)]'
|
||||||
|
}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--app-border)] pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemClass(homeMode === 'media')}
|
||||||
|
onClick={() => setHomeMode('media')}
|
||||||
|
>
|
||||||
|
<Clapperboard size={13} /> 影视
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemClass(homeMode === 'reading')}
|
||||||
|
onClick={() => setHomeMode('reading')}
|
||||||
|
>
|
||||||
|
<BookOpen size={13} /> 阅读
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { ArrowLeft, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderReplaceRule, type ReplaceRuleInput } from '../../api/reader'
|
||||||
|
|
||||||
|
// 替换净化规则页(仿 legado ReplaceRuleActivity:列表 + 启停 + 编辑)。
|
||||||
|
|
||||||
|
const emptyInput: ReplaceRuleInput = {
|
||||||
|
name: '',
|
||||||
|
group: '',
|
||||||
|
pattern: '',
|
||||||
|
replacement: '',
|
||||||
|
scope: '',
|
||||||
|
scope_title: false,
|
||||||
|
scope_content: true,
|
||||||
|
exclude_scope: '',
|
||||||
|
is_enabled: true,
|
||||||
|
is_regex: true,
|
||||||
|
timeout_millisecond: 3000,
|
||||||
|
order: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReaderReplacePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [rules, setRules] = useState<ReaderReplaceRule[] | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [form, setForm] = useState<ReplaceRuleInput>(emptyInput)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
readerAPI
|
||||||
|
.listReplaceRules()
|
||||||
|
.then(setRules)
|
||||||
|
.catch(() => toast.error('加载替换规则失败'))
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form.pattern.trim()) {
|
||||||
|
toast.error('替换规则不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await readerAPI.createReplaceRule(form)
|
||||||
|
toast.success('已添加')
|
||||||
|
setForm(emptyInput)
|
||||||
|
setShowForm(false)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = async (r: ReaderReplaceRule) => {
|
||||||
|
try {
|
||||||
|
await readerAPI.updateReplaceRule(r.id, {
|
||||||
|
name: r.name,
|
||||||
|
group: r.group,
|
||||||
|
pattern: r.pattern,
|
||||||
|
replacement: r.replacement,
|
||||||
|
scope: r.scope,
|
||||||
|
scope_title: r.scope_title,
|
||||||
|
scope_content: r.scope_content,
|
||||||
|
exclude_scope: r.exclude_scope,
|
||||||
|
is_enabled: !r.is_enabled,
|
||||||
|
is_regex: r.is_regex,
|
||||||
|
timeout_millisecond: 3000,
|
||||||
|
order: r.order,
|
||||||
|
})
|
||||||
|
setRules((prev) => prev?.map((x) => (x.id === r.id ? { ...x, is_enabled: !x.is_enabled } : x)) ?? null)
|
||||||
|
} catch {
|
||||||
|
toast.error('更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (r: ReaderReplaceRule) => {
|
||||||
|
if (!window.confirm(`删除规则「${r.name || r.pattern}」?`)) return
|
||||||
|
try {
|
||||||
|
await readerAPI.deleteReplaceRule(r.id)
|
||||||
|
setRules((prev) => prev?.filter((x) => x.id !== r.id) ?? null)
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<h1 className="flex-1 font-display text-lg text-ink-600">替换净化</h1>
|
||||||
|
<button type="button" onClick={() => setShowForm((v) => !v)} className="btn-primary text-xs">
|
||||||
|
<Plus size={13} className="mr-1 inline" /> 添加规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="mt-4 space-y-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="规则名(可选)"
|
||||||
|
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={form.group}
|
||||||
|
onChange={(e) => setForm({ ...form, group: e.target.value })}
|
||||||
|
placeholder="分组(可选)"
|
||||||
|
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={form.pattern}
|
||||||
|
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="替换规则(正则或原文)"
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={form.replacement}
|
||||||
|
onChange={(e) => setForm({ ...form, replacement: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="替换为(留空即删除匹配内容)"
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--app-muted)]">
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" checked={form.is_regex} onChange={(e) => setForm({ ...form, is_regex: e.target.checked })} />
|
||||||
|
正则
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" checked={form.scope_content} onChange={(e) => setForm({ ...form, scope_content: e.target.checked })} />
|
||||||
|
作用于正文
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.scope}
|
||||||
|
onChange={(e) => setForm({ ...form, scope: e.target.value })}
|
||||||
|
placeholder="作用范围(书名包含,可选)"
|
||||||
|
className="w-44 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={form.exclude_scope}
|
||||||
|
onChange={(e) => setForm({ ...form, exclude_scope: e.target.value })}
|
||||||
|
placeholder="排除范围(可选)"
|
||||||
|
className="w-44 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={save} disabled={saving} className="btn-primary ml-auto text-xs disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 size={13} className="inline animate-spin" /> : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{rules === null && (
|
||||||
|
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rules !== null && rules.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center text-xs text-[var(--app-muted)]">
|
||||||
|
还没有替换规则。规则按顺序作用于所有书籍正文,可用来去除广告、修正错字。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rules?.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--app-text)]">
|
||||||
|
{r.name || '(未命名规则)'}
|
||||||
|
{r.is_regex && <span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">正则</span>}
|
||||||
|
{r.group && <span className="ml-2 text-2xs font-normal text-[var(--app-muted)]">{r.group}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-2xs text-[var(--app-muted)]">
|
||||||
|
{r.pattern} → {r.replacement || '(删除)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => remove(r)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={r.is_enabled}
|
||||||
|
onClick={() => toggle(r)}
|
||||||
|
className={`relative h-5 w-9 shrink-0 rounded-full transition ${r.is_enabled ? 'bg-brand-500' : 'bg-[var(--app-hover)]'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||||
|
r.is_enabled ? 'left-[18px]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import ReaderBookPage from './ReaderBookPage'
|
||||||
|
import ReaderHomePage from './ReaderHomePage'
|
||||||
|
import ReaderReplacePage from './ReaderReplacePage'
|
||||||
|
import ReaderSearchPage from './ReaderSearchPage'
|
||||||
|
import ReaderSourcesPage from './ReaderSourcesPage'
|
||||||
|
import ReaderViewPage from './ReaderViewPage'
|
||||||
|
|
||||||
|
// 阅读独立布局路由(不套影视 Layout,全屏沉浸;挂在 /reader/* 下)。
|
||||||
|
export default function ReaderRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<ReaderHomePage />} />
|
||||||
|
<Route path="search" element={<ReaderSearchPage />} />
|
||||||
|
<Route path="sources" element={<ReaderSourcesPage />} />
|
||||||
|
<Route path="replace" element={<ReaderReplacePage />} />
|
||||||
|
<Route path="book" element={<ReaderBookPage />} />
|
||||||
|
<Route path="view/:bookId" element={<ReaderViewPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/reader" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
|
||||||
|
|
||||||
|
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||||
|
|
||||||
|
export default function ReaderSearchPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const [key, setKey] = useState(() => params.get('key') ?? '')
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
|
||||||
|
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||||
|
const [showSkipped, setShowSkipped] = useState(false)
|
||||||
|
const [adding, setAdding] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
const kw = key.trim()
|
||||||
|
if (!kw || searching) return
|
||||||
|
setSearching(true)
|
||||||
|
setShowSkipped(false)
|
||||||
|
try {
|
||||||
|
const res = await readerAPI.search(kw)
|
||||||
|
setBooks(res.books ?? [])
|
||||||
|
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||||
|
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||||
|
setSkipped(res.skipped ?? [])
|
||||||
|
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
|
||||||
|
const autoSearchedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSearchedRef.current) return
|
||||||
|
autoSearchedRef.current = true
|
||||||
|
if (key.trim()) void doSearch()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
||||||
|
if (!origin) return
|
||||||
|
setAdding(book.book_url)
|
||||||
|
try {
|
||||||
|
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
||||||
|
toast.success(`《${book.name}》已加入书架`)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setAdding('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBook = async (book: ReaderSearchBook) => {
|
||||||
|
const origin = book.origins[0]
|
||||||
|
if (!origin) return
|
||||||
|
navigate(
|
||||||
|
`/reader/book?${new URLSearchParams({
|
||||||
|
source_url: origin.origin,
|
||||||
|
book_url: origin.book_url,
|
||||||
|
name: book.name,
|
||||||
|
author: book.author,
|
||||||
|
cover_url: book.cover_url,
|
||||||
|
origin_id: origin.source_id,
|
||||||
|
origin_name: origin.origin_name,
|
||||||
|
}).toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2">
|
||||||
|
<Search size={15} className="text-[var(--app-muted)]" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||||
|
placeholder="搜索书名或作者"
|
||||||
|
className="w-full bg-transparent text-sm text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={doSearch}
|
||||||
|
disabled={searching || !key.trim()}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{searching ? <Loader2 size={13} className="animate-spin" /> : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searching && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={22} />
|
||||||
|
<p className="text-xs">正在并发搜索启用的书源(单源最长 30 秒)…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searching && books !== null && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{books.map((book) => (
|
||||||
|
<div
|
||||||
|
key={`${book.name}|${book.author}`}
|
||||||
|
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => openBook(book)} className="shrink-0">
|
||||||
|
<div className="h-24 w-16 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||||
|
{book.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={book.cover_url}
|
||||||
|
alt={book.name}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<BookOpen size={18} className="text-[var(--app-muted)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button type="button" onClick={() => openBook(book)} className="text-left">
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">
|
||||||
|
{book.name}
|
||||||
|
{book.origins.length > 1 && (
|
||||||
|
<span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||||||
|
{book.origins.length} 源可换
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
|
||||||
|
{book.author || '佚名'}
|
||||||
|
{book.kind ? ` · ${book.kind}` : ''}
|
||||||
|
{book.word_count ? ` · ${book.word_count}` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">最新:{book.latest_chapter || '未知'}</p>
|
||||||
|
{book.intro && <p className="mt-1 line-clamp-2 text-xs text-[var(--app-subtle)]">{book.intro}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToShelf(book)}
|
||||||
|
disabled={adding === book.book_url}
|
||||||
|
className="btn-outline text-2xs"
|
||||||
|
>
|
||||||
|
{adding === book.book_url ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
|
||||||
|
加书架
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{skipped.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSkipped((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-3 text-xs font-bold text-[var(--app-muted)]"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} className="text-amber-500" />
|
||||||
|
{skipped.length} 个书源搜索失败
|
||||||
|
{showSkipped ? <ChevronDown size={13} className="ml-auto" /> : <ChevronRight size={13} className="ml-auto" />}
|
||||||
|
</button>
|
||||||
|
{showSkipped && (
|
||||||
|
<div className="space-y-1.5 px-4 pb-3">
|
||||||
|
{skipped.map((s) => (
|
||||||
|
<div key={s.source_id} className="flex items-start gap-2 text-xs text-[var(--app-muted)]">
|
||||||
|
<span className="shrink-0 font-bold text-[var(--app-text)]">{s.origin_name}</span>
|
||||||
|
<span className="min-w-0 break-all">{s.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link to="/reader/sources" className="inline-block pt-1 text-2xs text-brand-600 hover:underline">
|
||||||
|
去书源管理排查 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searching && books === null && (
|
||||||
|
<div className="py-24 text-center text-xs text-[var(--app-muted)]">
|
||||||
|
输入关键词开始搜索 · 需要 <Link to="/reader/sources" className="text-brand-600 hover:underline">先导入书源</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { ArrowLeft, Bug, Download, FileUp, KeyRound, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderSource } from '../../api/reader'
|
||||||
|
import { READER_SOURCE_TYPES } from './sourceTypes'
|
||||||
|
import SourceLoginDialog from './SourceLoginDialog'
|
||||||
|
|
||||||
|
// 书源管理页(仿 legado 书源列表:启停开关、快速调试、导入)。
|
||||||
|
|
||||||
|
// 导入书源文件大小上限(legado 全量导出通常 < 10MB)
|
||||||
|
const MAX_IMPORT_FILE_BYTES = 32 << 20
|
||||||
|
|
||||||
|
function hostOf(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).host
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes: number): string {
|
||||||
|
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`
|
||||||
|
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`
|
||||||
|
return `${bytes}B`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReaderSourcesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [sources, setSources] = useState<ReaderSource[] | null>(null)
|
||||||
|
const [importText, setImportText] = useState('')
|
||||||
|
const [importFile, setImportFile] = useState<{ name: string; text: string; size: number } | null>(null)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [debugId, setDebugId] = useState('')
|
||||||
|
const [debugKey, setDebugKey] = useState('')
|
||||||
|
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
|
||||||
|
const [debugging, setDebugging] = useState(false)
|
||||||
|
// 登录面板:记录正在登录的书源
|
||||||
|
const [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
|
||||||
|
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
readerAPI
|
||||||
|
.listSources()
|
||||||
|
.then(setSources)
|
||||||
|
.catch(() => toast.error('加载书源失败'))
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
|
||||||
|
const readFile = async (file: File) => {
|
||||||
|
if (file.size > MAX_IMPORT_FILE_BYTES) {
|
||||||
|
toast.error('文件过大(超过 32MB)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = (await file.text()).replace(/^\uFEFF/, '')
|
||||||
|
setImportFile({ name: file.name, text, size: file.size })
|
||||||
|
} catch {
|
||||||
|
toast.error('读取文件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doImport = async () => {
|
||||||
|
const payload = importFile?.text ?? importText
|
||||||
|
if (!payload.trim() || importing) return
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
const imported = await readerAPI.importSources(payload)
|
||||||
|
toast.success(`成功导入 ${imported} 个书源`)
|
||||||
|
setImportText('')
|
||||||
|
setImportFile(null)
|
||||||
|
setShowImport(false)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEnabled = async (src: ReaderSource) => {
|
||||||
|
try {
|
||||||
|
await readerAPI.setSourceEnabled(src.id, !src.enabled)
|
||||||
|
setSources((prev) => prev?.map((s) => (s.id === src.id ? { ...s, enabled: !s.enabled } : s)) ?? null)
|
||||||
|
} catch {
|
||||||
|
toast.error('更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSource = async (src: ReaderSource) => {
|
||||||
|
if (!window.confirm(`删除书源「${src.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await readerAPI.deleteSource(src.id)
|
||||||
|
setSources((prev) => prev?.filter((s) => s.id !== src.id) ?? null)
|
||||||
|
toast.success('已删除')
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDebug = async (src: ReaderSource) => {
|
||||||
|
if (!debugKey.trim()) {
|
||||||
|
toast.error('先填一个搜索关键词')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDebugging(true)
|
||||||
|
setDebugLogs(null)
|
||||||
|
try {
|
||||||
|
const logs = await readerAPI.debugSource(src.id, debugKey.trim())
|
||||||
|
setDebugLogs(logs)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '调试失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setDebugging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<h1 className="flex-1 font-display text-lg text-ink-600">书源管理</h1>
|
||||||
|
<Link to="/reader/replace" className="btn-outline mr-2 text-xs">
|
||||||
|
替换净化
|
||||||
|
</Link>
|
||||||
|
<button type="button" onClick={() => setShowImport((v) => !v)} className="btn-primary text-xs">
|
||||||
|
<Download size={13} className="mr-1 inline" /> 导入书源
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(false)
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) readFile(file)
|
||||||
|
}}
|
||||||
|
className={`mt-4 rounded-2xl border bg-[var(--app-panel)] p-4 transition ${
|
||||||
|
dragOver ? 'border-brand-500 bg-brand-500/5' : 'border-[var(--app-border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,.txt,application/json,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) readFile(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={importText}
|
||||||
|
onChange={(e) => setImportText(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder={'粘贴书源 JSON / Base64,或填一个书源链接(https://...)\n也可选择 / 拖入书源文件(.json / .txt),支持数组批量导入'}
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||||
|
/>
|
||||||
|
{importFile && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 rounded-xl border border-brand-500/30 bg-brand-500/10 px-3 py-1.5 text-xs font-bold text-brand-600">
|
||||||
|
<FileUp size={13} className="shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{importFile.name}({fmtSize(importFile.size)})
|
||||||
|
</span>
|
||||||
|
<button type="button" title="移除文件" onClick={() => setImportFile(null)} className="shrink-0 hover:text-red-500">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-outline text-xs">
|
||||||
|
<FileUp size={13} className="mr-1 inline" /> 选择文件
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={doImport}
|
||||||
|
disabled={importing || !(importFile || importText.trim())}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importing ? <Loader2 size={13} className="inline animate-spin" /> : '开始导入'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{sources === null && (
|
||||||
|
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sources !== null && sources.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center text-xs text-[var(--app-muted)]">
|
||||||
|
还没有书源,点右上「导入书源」开始
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sources?.map((src) => (
|
||||||
|
<div key={src.id} className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center gap-2 truncate text-sm font-bold text-[var(--app-text)]">
|
||||||
|
{src.name}
|
||||||
|
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||||||
|
{READER_SOURCE_TYPES[src.type] ?? '文本'}
|
||||||
|
</span>
|
||||||
|
{src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
|
||||||
|
</div>
|
||||||
|
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
|
||||||
|
{src.has_login && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={loggedInIds[src.id] ? '已登录,点击管理' : '登录'}
|
||||||
|
onClick={() => setLoginSource(src)}
|
||||||
|
className={`rounded-xl p-2 hover:bg-[var(--app-hover)] ${
|
||||||
|
loggedInIds[src.id] ? 'text-emerald-500' : 'text-[var(--app-muted)] hover:text-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="快速调试"
|
||||||
|
onClick={() => {
|
||||||
|
setDebugId(debugId === src.id ? '' : src.id)
|
||||||
|
setDebugLogs(null)
|
||||||
|
}}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Bug size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => removeSource(src)}
|
||||||
|
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
{/* 启停开关(仿 legado Switch) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={src.enabled}
|
||||||
|
onClick={() => toggleEnabled(src)}
|
||||||
|
className={`relative h-5 w-9 shrink-0 rounded-full transition ${
|
||||||
|
src.enabled ? 'bg-brand-500' : 'bg-[var(--app-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||||
|
src.enabled ? 'left-[18px]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{debugId === src.id && (
|
||||||
|
<div className="border-t border-[var(--app-border)] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={debugKey}
|
||||||
|
onChange={(e) => setDebugKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && doDebug(src)}
|
||||||
|
placeholder="搜索关键词(如:斗破苍穹)"
|
||||||
|
className="flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => doDebug(src)} disabled={debugging} className="btn-outline text-2xs disabled:opacity-50">
|
||||||
|
{debugging ? <Loader2 size={12} className="inline animate-spin" /> : <Play size={12} className="inline" />}
|
||||||
|
运行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{debugLogs !== null && (
|
||||||
|
<pre className="mt-3 max-h-72 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
|
||||||
|
{debugLogs.length === 0 ? '(无日志)' : debugLogs.join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginSource && (
|
||||||
|
<SourceLoginDialog
|
||||||
|
sourceId={loginSource.id}
|
||||||
|
sourceName={loginSource.name}
|
||||||
|
onClose={() => {
|
||||||
|
setLoginSource(null)
|
||||||
|
// 关闭时回读登录态,更新列表上的登录标记
|
||||||
|
readerAPI
|
||||||
|
.sourceLogin(loginSource.id)
|
||||||
|
.then((info) => setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: info.logged_in })))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}}
|
||||||
|
onLoggedInChange={(loggedIn) =>
|
||||||
|
setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: loggedIn }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
LayoutList,
|
||||||
|
ListEnd,
|
||||||
|
Loader2,
|
||||||
|
Minus,
|
||||||
|
Moon,
|
||||||
|
Plus,
|
||||||
|
ScrollText,
|
||||||
|
Sun,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Virtuoso } from 'react-virtuoso'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
|
||||||
|
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
|
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||||
|
import { ReaderComic } from './ReaderComic'
|
||||||
|
|
||||||
|
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
|
||||||
|
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||||
|
|
||||||
|
const COLUMN_GAP = 48
|
||||||
|
|
||||||
|
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
|
||||||
|
const IMG_MARK = '[img]'
|
||||||
|
|
||||||
|
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||||
|
const MENU_SHIFT = 'transition-transform duration-200'
|
||||||
|
|
||||||
|
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
|
||||||
|
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
|
||||||
|
const WHEEL_NOTCH = 40
|
||||||
|
/** 触控板小步长累计到这个量翻一页。 */
|
||||||
|
const WHEEL_SWIPE_THRESHOLD = 60
|
||||||
|
/** 两次翻页的最小间隔,与 220ms 翻页动画对齐。 */
|
||||||
|
const WHEEL_TURN_COOLDOWN = 220
|
||||||
|
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
|
||||||
|
const WHEEL_GESTURE_GAP = 180
|
||||||
|
|
||||||
|
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||||
|
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||||
|
if (i !== -1) return i
|
||||||
|
// 本地导入的章节没有 url,退回第一个非卷名章
|
||||||
|
const j = chapters.findIndex((c) => !c.is_volume)
|
||||||
|
return j === -1 ? 0 : j
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReaderViewPage() {
|
||||||
|
const { bookId = '' } = useParams()
|
||||||
|
const settings = useReaderSettingsStore()
|
||||||
|
const theme = getReaderTheme(settings.themeId, settings.night)
|
||||||
|
|
||||||
|
const [book, setBook] = useState<ReaderBook | null>(null)
|
||||||
|
const [chapters, setChapters] = useState<ReaderChapter[]>([])
|
||||||
|
const [chapterIndex, setChapterIndex] = useState<number | null>(null)
|
||||||
|
const [content, setContent] = useState<string | null>(null)
|
||||||
|
const [contentType, setContentType] = useState<'text' | 'audio' | 'image'>('text')
|
||||||
|
const [loadingStage, setLoadingStage] = useState<'book' | 'content' | null>('book')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
||||||
|
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||||
|
const topBarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuInset, setMenuInset] = useState(0)
|
||||||
|
// 滚轮翻页的累计量 / 冷却 / 一次手势只翻一页的锁
|
||||||
|
const readerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const wheelAccumRef = useRef(0)
|
||||||
|
const wheelLastEventRef = useRef(0)
|
||||||
|
const wheelLastTurnRef = useRef(0)
|
||||||
|
const wheelSwipeLockedRef = useRef(false)
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pageCount, setPageCount] = useState(1)
|
||||||
|
const [vw, setVw] = useState(0)
|
||||||
|
// 音频/漫画媒体状态
|
||||||
|
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
|
||||||
|
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
|
||||||
|
const [comicPage, setComicPage] = useState(0)
|
||||||
|
const [currentImage, setCurrentImage] = useState(0)
|
||||||
|
const [scrollToImage, setScrollToImage] = useState<number | null>(null)
|
||||||
|
const lastMediaSaveRef = useRef(0)
|
||||||
|
const contentCache = useRef(new Map<string, ReaderChapterContent>())
|
||||||
|
const pendingPosRef = useRef(0)
|
||||||
|
const pendingEndRef = useRef(false)
|
||||||
|
|
||||||
|
// ── 加载书籍与章节 ──
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingStage('book')
|
||||||
|
setError('')
|
||||||
|
const books = await readerAPI.listBooks()
|
||||||
|
const b = books.find((x) => x.id === bookId)
|
||||||
|
if (!b) throw new Error('书籍不存在或已移出书架')
|
||||||
|
if (cancelled) return
|
||||||
|
setBook(b)
|
||||||
|
let chs = await readerAPI.listChapters(b.id)
|
||||||
|
if (chs.length === 0) {
|
||||||
|
if (b.is_local) throw new Error('本地书籍目录为空,请重新导入该书')
|
||||||
|
const toc = await readerAPI.toc({
|
||||||
|
source_url: b.origin,
|
||||||
|
book_url: b.book_url,
|
||||||
|
toc_url: b.toc_url || b.book_url,
|
||||||
|
})
|
||||||
|
if (toc.length === 0) throw new Error('目录为空,尝试到详情页刷新目录')
|
||||||
|
const toSave = toc.map((c) => ({ index: c.index, title: c.title, url: c.url, is_volume: c.is_volume }))
|
||||||
|
await readerAPI.saveChapters(b.id, toSave).catch(() => undefined)
|
||||||
|
chs = toSave
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
setChapters(chs)
|
||||||
|
// URL 上带 chapter 才用它;没有这个参数就不能当成 0,否则每次进来都回第一章
|
||||||
|
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
|
||||||
|
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
|
||||||
|
let idx = firstReadableIndex(chs)
|
||||||
|
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
|
||||||
|
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||||
|
idx = b.dur_chapter_index
|
||||||
|
}
|
||||||
|
pendingPosRef.current = b.dur_chapter_pos ?? 0
|
||||||
|
setChapterIndex(idx)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError((e as Error).message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingStage(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
contentCache.current.clear()
|
||||||
|
}
|
||||||
|
}, [bookId])
|
||||||
|
|
||||||
|
// ── 加载章节正文(带缓存与下一章预取) ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (chapterIndex === null || !book || chapters.length === 0) return
|
||||||
|
const ch = chapters[chapterIndex]
|
||||||
|
if (!ch) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setContent(null)
|
||||||
|
setLoadingStage('content')
|
||||||
|
try {
|
||||||
|
const cacheKey = String(chapterIndex)
|
||||||
|
let ct = contentCache.current.get(cacheKey)
|
||||||
|
if (!ct) {
|
||||||
|
ct = await readerAPI.bookContent(book.id, chapterIndex)
|
||||||
|
contentCache.current.set(cacheKey, ct)
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
setContentType(ct.type)
|
||||||
|
setMedia(ct)
|
||||||
|
setContent(ct.content ?? '')
|
||||||
|
setPage(0)
|
||||||
|
setComicPage(0)
|
||||||
|
setCurrentImage(0)
|
||||||
|
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
|
||||||
|
if (ct.type !== 'text') {
|
||||||
|
setRestorePos(pendingPosRef.current)
|
||||||
|
pendingPosRef.current = 0
|
||||||
|
}
|
||||||
|
// 进度上报(pos 保留原值,排版完成后才被消费清零)
|
||||||
|
readerAPI
|
||||||
|
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
|
||||||
|
.catch(() => undefined)
|
||||||
|
// 预取下一章
|
||||||
|
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||||
|
readerAPI
|
||||||
|
.bookContent(book.id, chapterIndex + 1)
|
||||||
|
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? (e as Error).message
|
||||||
|
setError(msg || '正文加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingStage(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [chapterIndex, book, chapters])
|
||||||
|
|
||||||
|
// ── 分页排版(CSS 多栏 + 平移) ──
|
||||||
|
const relayout = useCallback(() => {
|
||||||
|
const vp = viewportRef.current
|
||||||
|
if (!vp) return
|
||||||
|
const width = vp.clientWidth
|
||||||
|
setVw(width)
|
||||||
|
const total = vp.scrollWidth
|
||||||
|
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
|
||||||
|
setPageCount(count)
|
||||||
|
setPage((p) => {
|
||||||
|
if (pendingEndRef.current && count > 0) {
|
||||||
|
pendingEndRef.current = false
|
||||||
|
return count - 1
|
||||||
|
}
|
||||||
|
if (pendingPosRef.current > 0) {
|
||||||
|
const pos = Math.min(pendingPosRef.current, count - 1)
|
||||||
|
pendingPosRef.current = 0
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
return Math.min(p, count - 1)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
relayout()
|
||||||
|
}, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vp = viewportRef.current
|
||||||
|
if (!vp) return
|
||||||
|
const ro = new ResizeObserver(() => relayout())
|
||||||
|
ro.observe(vp)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [relayout, content, settings.pageMode])
|
||||||
|
|
||||||
|
// 滚动模式恢复进度
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.pageMode !== 'scroll' || content === null) return
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && pendingPosRef.current > 0) {
|
||||||
|
el.scrollTop = pendingPosRef.current
|
||||||
|
pendingPosRef.current = 0
|
||||||
|
}
|
||||||
|
}, [content, settings.pageMode])
|
||||||
|
|
||||||
|
// ── 进度保存(翻页 / 滚动) ──
|
||||||
|
const savePos = useCallback(
|
||||||
|
(pos: number) => {
|
||||||
|
if (!book || chapterIndex === null) return
|
||||||
|
const ch = chapters[chapterIndex]
|
||||||
|
readerAPI
|
||||||
|
.saveProgress(book.id, { chapter_index: chapterIndex, pos, chapter_title: ch?.title ?? '' })
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
[book, chapterIndex, chapters],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.pageMode !== 'page' || content === null || chapterIndex === null) return
|
||||||
|
const t = setTimeout(() => savePos(page), 1500)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [page, content, chapterIndex, settings.pageMode, savePos])
|
||||||
|
|
||||||
|
// 漫画单页进度保存
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentType !== 'image' || settings.pageMode !== 'page' || !media || chapterIndex === null) return
|
||||||
|
const t = setTimeout(() => savePos(comicPage), 1200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
|
||||||
|
|
||||||
|
// 音频/漫画滚动:节流进度保存
|
||||||
|
const throttledMediaSave = useCallback(
|
||||||
|
(pos: number) => {
|
||||||
|
if (!book || chapterIndex === null) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
|
||||||
|
lastMediaSaveRef.current = now
|
||||||
|
savePos(pos)
|
||||||
|
},
|
||||||
|
[book, chapterIndex, contentType, savePos],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 漫画:翻到章尾/恢复进度定位
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || media.type !== 'image') return
|
||||||
|
const n = media.images?.length ?? 0
|
||||||
|
if (pendingEndRef.current && n > 0) {
|
||||||
|
setComicPage(n - 1)
|
||||||
|
pendingEndRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (restorePos > 0) {
|
||||||
|
setComicPage(Math.min(restorePos, Math.max(0, n - 1)))
|
||||||
|
}
|
||||||
|
}, [media, restorePos])
|
||||||
|
|
||||||
|
// ── 章节导航 ──
|
||||||
|
const goChapter = useCallback(
|
||||||
|
(delta: number, atEnd = false) => {
|
||||||
|
if (chapterIndex === null) return
|
||||||
|
let i = chapterIndex + delta
|
||||||
|
while (i >= 0 && i < chapters.length && chapters[i].is_volume) i += delta
|
||||||
|
if (i < 0 || i >= chapters.length) {
|
||||||
|
toast(delta < 0 ? '已经是第一章' : '已经是最后一章')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingEndRef.current = atEnd
|
||||||
|
setError('')
|
||||||
|
setChapterIndex(i)
|
||||||
|
},
|
||||||
|
[chapterIndex, chapters],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 听书:进度条/章节列表跳章(不沿用 goChapter 的越界提示,直接落位)
|
||||||
|
const jumpToChapter = useCallback((idx: number) => {
|
||||||
|
if (idx < 0 || idx >= chapters.length) return
|
||||||
|
pendingEndRef.current = false
|
||||||
|
setError('')
|
||||||
|
setChapterIndex(idx)
|
||||||
|
}, [chapters.length])
|
||||||
|
|
||||||
|
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
|
||||||
|
const hasNextAudioChapter = useMemo(() => {
|
||||||
|
if (chapterIndex === null) return false
|
||||||
|
for (let i = chapterIndex + 1; i < chapters.length; i++) {
|
||||||
|
if (!chapters[i].is_volume) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [chapterIndex, chapters])
|
||||||
|
|
||||||
|
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
|
||||||
|
const saveAudioCredits = useCallback(
|
||||||
|
(open: number, close: number) => {
|
||||||
|
if (!book) return
|
||||||
|
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
|
||||||
|
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||||
|
},
|
||||||
|
[book],
|
||||||
|
)
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (contentType === 'audio') {
|
||||||
|
goChapter(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||||
|
if (comicPage > 0) setComicPage((p) => p - 1)
|
||||||
|
else goChapter(-1, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (settings.pageMode === 'scroll') {
|
||||||
|
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (page > 0) setPage((p) => p - 1)
|
||||||
|
else goChapter(-1, true)
|
||||||
|
}, [contentType, settings.pageMode, comicPage, page, goChapter])
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (contentType === 'audio') {
|
||||||
|
goChapter(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||||
|
const n = media?.images?.length ?? 0
|
||||||
|
if (comicPage < n - 1) setComicPage((p) => p + 1)
|
||||||
|
else goChapter(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (settings.pageMode === 'scroll') {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) goChapter(1)
|
||||||
|
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||||
|
else goChapter(1)
|
||||||
|
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
|
||||||
|
|
||||||
|
// ── 键盘(桌面端) ──
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (panel !== 'none') setPanel('none')
|
||||||
|
else if (menuOpen) setMenuOpen(false)
|
||||||
|
else setMenuOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'PageUp') goPrev()
|
||||||
|
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [goPrev, goNext, menuOpen, panel])
|
||||||
|
|
||||||
|
// ── 鼠标滚轮翻页(仅翻页模式) ──
|
||||||
|
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
|
||||||
|
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
|
||||||
|
useEffect(() => {
|
||||||
|
const el = readerRef.current
|
||||||
|
if (!el) return
|
||||||
|
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
|
||||||
|
if (settings.pageMode !== 'page' || menuOpen) return
|
||||||
|
if (contentType !== 'text' && contentType !== 'image') return
|
||||||
|
|
||||||
|
const turn = (forward: boolean) => (forward ? goNext() : goPrev())
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
// ctrl+滚轮是浏览器缩放,别抢
|
||||||
|
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
const now = Date.now()
|
||||||
|
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||||
|
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1
|
||||||
|
const delta = e.deltaY * unit
|
||||||
|
if (now - wheelLastEventRef.current > WHEEL_GESTURE_GAP) {
|
||||||
|
wheelAccumRef.current = 0
|
||||||
|
wheelSwipeLockedRef.current = false
|
||||||
|
}
|
||||||
|
wheelLastEventRef.current = now
|
||||||
|
|
||||||
|
if (Math.abs(delta) >= WHEEL_NOTCH) {
|
||||||
|
// 鼠标滚轮:一格一页,但至少隔一次翻页动画的时间
|
||||||
|
if (now - wheelLastTurnRef.current < WHEEL_TURN_COOLDOWN) return
|
||||||
|
wheelLastTurnRef.current = now
|
||||||
|
wheelAccumRef.current = 0
|
||||||
|
turn(delta > 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 触控板:小步长累计到阈值再翻,一次手势只翻一页,避免惯性连翻
|
||||||
|
if (wheelSwipeLockedRef.current) return
|
||||||
|
wheelAccumRef.current += delta
|
||||||
|
if (Math.abs(wheelAccumRef.current) < WHEEL_SWIPE_THRESHOLD) return
|
||||||
|
const forward = wheelAccumRef.current > 0
|
||||||
|
wheelAccumRef.current = 0
|
||||||
|
wheelSwipeLockedRef.current = true
|
||||||
|
wheelLastTurnRef.current = now
|
||||||
|
turn(forward)
|
||||||
|
}
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => el.removeEventListener('wheel', onWheel)
|
||||||
|
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
|
||||||
|
|
||||||
|
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||||
|
|
||||||
|
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
|
||||||
|
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
|
||||||
|
const menuShiftStyle = useMemo(
|
||||||
|
() => (menuOpen && menuInset > 0 ? { transform: `translateY(${menuInset}px)` } : undefined),
|
||||||
|
[menuOpen, menuInset],
|
||||||
|
)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menuOpen) {
|
||||||
|
setMenuInset(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const h = topBarRef.current?.offsetHeight ?? 0
|
||||||
|
setMenuInset((cur) => (cur === h ? cur : h))
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
|
// 菜单进度条按内容类型适配:文本=页/滚动位置,音频=章节,漫画=图片序号
|
||||||
|
const imageCount = media?.images?.length ?? 0
|
||||||
|
const sliderCfg = (() => {
|
||||||
|
if (contentType === 'audio') {
|
||||||
|
return {
|
||||||
|
min: 0,
|
||||||
|
max: Math.max(0, chapters.length - 1),
|
||||||
|
value: Math.max(0, chapterIndex ?? 0),
|
||||||
|
onChange: (v: number) => jumpToChapter(v),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contentType === 'image') {
|
||||||
|
const max = Math.max(0, imageCount - 1)
|
||||||
|
if (settings.pageMode === 'page') {
|
||||||
|
return { min: 0, max, value: Math.min(comicPage, max), onChange: (v: number) => setComicPage(v) }
|
||||||
|
}
|
||||||
|
return { min: 0, max, value: Math.min(currentImage, max), onChange: (v: number) => setScrollToImage(v) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
min: 1,
|
||||||
|
max: Math.max(1, settings.pageMode === 'page' ? pageCount : 1000),
|
||||||
|
value:
|
||||||
|
settings.pageMode === 'page'
|
||||||
|
? page + 1
|
||||||
|
: Math.round(
|
||||||
|
((scrollRef.current?.scrollTop ?? 0) /
|
||||||
|
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
onChange: (v: number) => {
|
||||||
|
if (settings.pageMode === 'page') setPage(v - 1)
|
||||||
|
else {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
|
||||||
|
const renderParagraph = (line: string, key: number) => {
|
||||||
|
if (line.startsWith(IMG_MARK)) {
|
||||||
|
return (
|
||||||
|
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
|
||||||
|
<img
|
||||||
|
src={line.slice(IMG_MARK.length)}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 渲染 ──
|
||||||
|
if (error && !book) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-[var(--app-bg)] text-[var(--app-text)]">
|
||||||
|
<p className="text-sm text-[var(--app-muted)]">{error}</p>
|
||||||
|
<button type="button" onClick={() => window.history.back()} className="btn-outline text-xs">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||||
|
{/* 正文视口 */}
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||||
|
{contentType === 'audio' ? (
|
||||||
|
media && media.tracks && media.tracks.length > 0 ? (
|
||||||
|
<ReaderAudioPanel
|
||||||
|
src={media.tracks[0]}
|
||||||
|
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||||
|
cover={book?.cover_url ?? ''}
|
||||||
|
theme={theme}
|
||||||
|
initialPos={restorePos}
|
||||||
|
openCredits={book?.open_credits ?? 0}
|
||||||
|
closeCredits={book?.close_credits ?? 0}
|
||||||
|
chapters={chapters}
|
||||||
|
chapterIndex={chapterIndex}
|
||||||
|
hasNext={hasNextAudioChapter}
|
||||||
|
transcoding={media.transcoding ?? false}
|
||||||
|
onProgress={throttledMediaSave}
|
||||||
|
onPrevChapter={() => goChapter(-1)}
|
||||||
|
onNextChapter={() => goChapter(1)}
|
||||||
|
onSelectChapter={jumpToChapter}
|
||||||
|
onEnded={() => goChapter(1)}
|
||||||
|
onCreditsChange={saveAudioCredits}
|
||||||
|
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||||
|
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : contentType === 'image' ? (
|
||||||
|
<ReaderComic
|
||||||
|
images={media?.images ?? []}
|
||||||
|
theme={theme}
|
||||||
|
mode={settings.pageMode}
|
||||||
|
page={comicPage}
|
||||||
|
onZone={(zone) => {
|
||||||
|
if (zone === 'center') setMenuOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
initialImage={restorePos}
|
||||||
|
onProgress={(idx) => {
|
||||||
|
setCurrentImage(idx)
|
||||||
|
throttledMediaSave(idx)
|
||||||
|
}}
|
||||||
|
scrollTo={scrollToImage}
|
||||||
|
onScrolled={() => setScrollToImage(null)}
|
||||||
|
/>
|
||||||
|
) : settings.pageMode === 'page' ? (
|
||||||
|
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
|
||||||
|
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
|
||||||
|
不改高度也就不触发重新分页) */
|
||||||
|
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
|
||||||
|
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
columnWidth: `${Math.max(vw, 1)}px`,
|
||||||
|
columnGap: `${COLUMN_GAP}px`,
|
||||||
|
columnFill: 'auto',
|
||||||
|
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||||
|
transition: 'transform 220ms ease',
|
||||||
|
fontSize: settings.fontSize,
|
||||||
|
lineHeight: settings.lineHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
if (chapterIndex === null) return
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max > 0) {
|
||||||
|
const pct = el.scrollTop / max
|
||||||
|
if (Math.abs(pct * 1000 - (Number(el.dataset.last) ?? -1) * 1000) > 20) {
|
||||||
|
el.dataset.last = String(pct)
|
||||||
|
savePos(Math.round(el.scrollTop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
|
||||||
|
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||||
|
>
|
||||||
|
<div className="py-4">
|
||||||
|
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载 / 错误 / 空内容态 */}
|
||||||
|
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
|
||||||
|
{loadingStage !== null ? (
|
||||||
|
<Loader2 className="animate-spin opacity-60" size={24} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm opacity-60">本章内容为空</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3" style={{ color: theme.text }}>
|
||||||
|
<p className="text-sm opacity-80">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError('')
|
||||||
|
if (chapterIndex !== null) {
|
||||||
|
contentCache.current.delete(String(chapterIndex))
|
||||||
|
setChapterIndex(chapterIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
|
||||||
|
style={{ borderColor: theme.text }}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
|
||||||
|
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||||
|
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="菜单"
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 页脚页码(翻页模式) */}
|
||||||
|
{settings.pageMode === 'page' && contentType === 'text' && content !== null && (
|
||||||
|
<div className="pointer-events-none pb-2 text-center text-2xs opacity-50" style={{ color: theme.text }}>
|
||||||
|
{page + 1} / {pageCount} · {currentChapter?.title ?? ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主菜单(仿 legado ReadMenu) */}
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭菜单"
|
||||||
|
className="fixed inset-0 z-40 cursor-default bg-black/30"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
{/* 顶栏 */}
|
||||||
|
<div
|
||||||
|
ref={topBarRef}
|
||||||
|
className="fixed inset-x-0 top-0 z-50 flex items-center gap-3 border-b px-4 py-3 backdrop-blur"
|
||||||
|
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="rounded-xl p-1.5 opacity-70 hover:opacity-100"
|
||||||
|
style={{ color: theme.text }}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1 text-center">
|
||||||
|
<p className="truncate text-sm font-bold">{book?.name ?? '阅读'}</p>
|
||||||
|
<p className="truncate text-2xs opacity-60">{currentChapter?.title ?? ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-9" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部菜单 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 space-y-3 rounded-t-2xl border-t px-4 pb-6 pt-4 backdrop-blur"
|
||||||
|
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
|
||||||
|
>
|
||||||
|
{/* 章节行 */}
|
||||||
|
<div className="flex items-center gap-3" style={{ color: theme.text }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goChapter(-1)}
|
||||||
|
className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
上一章
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={sliderCfg.min}
|
||||||
|
max={sliderCfg.max}
|
||||||
|
value={sliderCfg.value}
|
||||||
|
onChange={(e) => sliderCfg.onChange(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-current"
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goChapter(1)}
|
||||||
|
className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
下一章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动作行(目录 / 界面 / 夜间 / 模式) */}
|
||||||
|
<div className="grid grid-cols-4 pt-1" style={{ color: theme.text }}>
|
||||||
|
{([
|
||||||
|
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
|
||||||
|
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
|
||||||
|
{
|
||||||
|
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||||
|
label: settings.night ? '日间' : '夜间',
|
||||||
|
action: () => settings.toggleNight(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
|
||||||
|
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||||
|
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||||
|
},
|
||||||
|
]).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
onClick={item.action}
|
||||||
|
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="text-2xs">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
|
||||||
|
{panel === 'style' && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
|
||||||
|
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22', color: theme.text }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{READER_THEMES.map((t) => {
|
||||||
|
const bg = settings.night ? t.nightBg : t.bg
|
||||||
|
const active = settings.themeId === t.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => settings.setThemeId(t.id)}
|
||||||
|
className={`h-8 w-8 rounded-full border-2 ${active ? 'scale-110' : ''}`}
|
||||||
|
style={{ backgroundColor: bg, borderColor: active ? theme.accent : theme.text + '44' }}
|
||||||
|
title={t.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-3 text-xs">
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="opacity-70">字号</span>
|
||||||
|
<button type="button" onClick={() => settings.setFontSize(settings.fontSize - 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center font-bold">{settings.fontSize}</span>
|
||||||
|
<button type="button" onClick={() => settings.setFontSize(settings.fontSize + 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="opacity-70">行距</span>
|
||||||
|
<button type="button" onClick={() => settings.setLineHeight(settings.lineHeight - 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center font-bold">{settings.lineHeight.toFixed(1)}</span>
|
||||||
|
<button type="button" onClick={() => settings.setLineHeight(settings.lineHeight + 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="opacity-70">段距</span>
|
||||||
|
<button type="button" onClick={() => settings.setParagraphSpacing(settings.paragraphSpacing - 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center font-bold">{settings.paragraphSpacing}</span>
|
||||||
|
<button type="button" onClick={() => settings.setParagraphSpacing(settings.paragraphSpacing + 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 目录:整屏面板。必须放在底部菜单之外——菜单带 backdrop-blur,
|
||||||
|
会成为 fixed 后代的包含块,放里面高度会被算成菜单的高度。 */}
|
||||||
|
{panel === 'toc' && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex flex-col"
|
||||||
|
style={{ backgroundColor: theme.bg, color: theme.text }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 border-b px-4 py-3"
|
||||||
|
style={{ borderColor: theme.text + '22' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPanel('none')}
|
||||||
|
className="rounded-xl p-1.5 opacity-70 hover:opacity-100"
|
||||||
|
aria-label="收起目录"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<p className="flex-1 truncate text-sm font-bold">
|
||||||
|
{book?.name ?? '目录'}
|
||||||
|
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<Virtuoso
|
||||||
|
data={chapters}
|
||||||
|
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||||
|
itemContent={(index, ch) => {
|
||||||
|
const isCurrent = index === chapterIndex
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPanel('none')
|
||||||
|
setMenuOpen(false)
|
||||||
|
jumpToChapter(index)
|
||||||
|
}}
|
||||||
|
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||||
|
ch.is_volume ? 'font-bold opacity-70' : ''
|
||||||
|
}`}
|
||||||
|
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
|
||||||
|
>
|
||||||
|
{ch.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ChevronRight, FileAudio, FileText, Folder, FolderPlus, HardDrive, Loader2, X } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { filesAPI, type FileEntry } from '../../api/files'
|
||||||
|
|
||||||
|
function apiErrorMessage(err: unknown): string {
|
||||||
|
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||||
|
const res = (err as { response?: { data?: { error?: string; message?: string } } }).response
|
||||||
|
if (res?.data?.error) return res.data.error
|
||||||
|
if (res?.data?.message) return res.data.message
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return '请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes) return ''
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let value = bytes
|
||||||
|
let unit = 0
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unit += 1
|
||||||
|
}
|
||||||
|
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerFilePickerDialog 选择服务器上已有的文件或目录。
|
||||||
|
*
|
||||||
|
* 走 /api/files(仅管理员,且只能浏览已配置的存储根目录),供阅读模块
|
||||||
|
* 「从服务器导入书籍 / 有声书」使用。
|
||||||
|
*/
|
||||||
|
export function ServerFilePickerDialog({
|
||||||
|
mode,
|
||||||
|
extensions,
|
||||||
|
title,
|
||||||
|
initialDir,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
mode: 'file' | 'dir'
|
||||||
|
/** mode=file 时限制可选的后缀,小写带点(如 ['.txt', '.epub']);留空表示不限制 */
|
||||||
|
extensions?: string[]
|
||||||
|
title: string
|
||||||
|
initialDir?: string
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [listing, setListing] = useState<{
|
||||||
|
path: string
|
||||||
|
parent?: string
|
||||||
|
roots?: { label: string; path: string }[]
|
||||||
|
entries: FileEntry[] | null
|
||||||
|
} | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async (target: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await filesAPI.list(target)
|
||||||
|
setListing({ path: data.path, parent: data.parent, roots: data.roots, entries: data.entries })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(initialDir ?? '')
|
||||||
|
}, [initialDir, load])
|
||||||
|
|
||||||
|
const atRoot = !listing?.path
|
||||||
|
// 后端返回的 ext 不带点("epub"),这里统一去掉点再比较
|
||||||
|
const normalizeExt = (raw?: string) => (raw ?? '').toLowerCase().replace(/^\./, '')
|
||||||
|
const wanted = (extensions ?? []).map(normalizeExt)
|
||||||
|
const accept = (entry: FileEntry) => {
|
||||||
|
if (entry.is_dir) return true
|
||||||
|
if (mode === 'dir') return false
|
||||||
|
if (wanted.length === 0) return true
|
||||||
|
return wanted.includes(normalizeExt(entry.ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = listing?.roots ?? []
|
||||||
|
const entries = (listing?.entries ?? []).filter(accept)
|
||||||
|
// 根目录列表和目录内条目是两个来源,空态判断要分别看
|
||||||
|
const itemCount = atRoot ? roots.length : entries.length
|
||||||
|
const canPickCurrent = mode === 'dir' && !atRoot
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||||
|
<h3 className="font-display text-lg font-bold text-ink-600">{title}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
|
||||||
|
{atRoot ? (
|
||||||
|
<span className="text-ink-50">选择存储位置</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="hover:text-brand-500" onClick={() => load('')}>
|
||||||
|
存储位置
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
<span className="truncate text-ink-50" title={listing?.path}>
|
||||||
|
{listing?.path}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[280px] flex-1 overflow-y-auto p-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-10 text-ink-50">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : itemCount === 0 ? (
|
||||||
|
<p className="py-10 text-center text-sm text-sand-500">
|
||||||
|
{atRoot
|
||||||
|
? '没有可用的存储位置'
|
||||||
|
: mode === 'dir'
|
||||||
|
? '该目录下没有子目录,可直接选择当前目录'
|
||||||
|
: '该目录下没有可选文件'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{atRoot ? (
|
||||||
|
roots.map((root) => (
|
||||||
|
<button
|
||||||
|
key={root.path}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||||
|
onClick={() => load(root.path)}
|
||||||
|
>
|
||||||
|
<HardDrive size={16} className="text-brand-400" />
|
||||||
|
<span className="flex-1 truncate text-ink-600">{root.label}</span>
|
||||||
|
<span className="truncate text-xs text-sand-400">{root.path}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(listing?.parent ?? '') !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm text-ink-50 transition hover:bg-gray-50"
|
||||||
|
onClick={() => load(listing?.parent ?? '')}
|
||||||
|
>
|
||||||
|
<FolderPlus size={16} className="text-sand-400" />
|
||||||
|
<span>..(上级目录)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||||
|
onClick={() => (entry.is_dir ? load(entry.path) : onSelect(entry.path))}
|
||||||
|
>
|
||||||
|
{entry.is_dir ? (
|
||||||
|
<Folder size={16} className="text-brand-400" />
|
||||||
|
) : normalizeExt(entry.ext) === 'strm' ? (
|
||||||
|
<FileAudio size={16} className="text-sand-400" />
|
||||||
|
) : (
|
||||||
|
<FileText size={16} className="text-sand-400" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
|
||||||
|
{!entry.is_dir && <span className="text-xs text-sand-400">{formatSize(entry.size)}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
{atRoot
|
||||||
|
? '先选择一个存储位置'
|
||||||
|
: mode === 'dir'
|
||||||
|
? '单击目录进入下一级,点击「选择当前目录」完成选择'
|
||||||
|
: '单击目录进入下一级,单击文件即完成选择'}
|
||||||
|
</span>
|
||||||
|
{canPickCurrent && (
|
||||||
|
<button type="button" className="neon-button" disabled={loading} onClick={() => onSelect(listing?.path ?? '')}>
|
||||||
|
选择当前目录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { readerAPI, type ReaderBrowserPage, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader'
|
||||||
|
import BrowserPanel from './BrowserPanel'
|
||||||
|
|
||||||
|
// 书源登录面板(仿 legado SourceLoginDialog)。
|
||||||
|
//
|
||||||
|
// 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑,
|
||||||
|
// 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。
|
||||||
|
// 服务端没有弹窗,java.toast/longToast 的提示通过返回值回传展示;
|
||||||
|
// java.startBrowser / startBrowserAwait 则把页面登记成「待办」,这里轮询到后
|
||||||
|
// 用内嵌 iframe 承载(见 BrowserPanel),用户的「√」会把 DOM 回传给书源。
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sourceId: string
|
||||||
|
sourceName: string
|
||||||
|
onClose: () => void
|
||||||
|
onLoggedInChange?: (loggedIn: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// formValues 取非按钮控件的初始值(已保存值优先,其次 default)。
|
||||||
|
function initialFormValues(info: ReaderSourceLogin): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const f of info.fields) {
|
||||||
|
if (f.type === 'button') continue
|
||||||
|
out[f.name] = info.values?.[f.name] ?? f.default ?? ''
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLoggedInChange }: Props) {
|
||||||
|
const [info, setInfo] = useState<ReaderSourceLogin | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [running, setRunning] = useState('')
|
||||||
|
const [form, setForm] = useState<Record<string, string>>({})
|
||||||
|
const [toasts, setToasts] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [variable, setVariable] = useState('')
|
||||||
|
const [showVariable, setShowVariable] = useState(false)
|
||||||
|
const [showLoginJS, setShowLoginJS] = useState(false)
|
||||||
|
// 书源交给宿主浏览器承载的页面(java.startBrowser / startBrowserAwait)
|
||||||
|
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||||
|
// 已处理过的页面 ID:避免轮询把刚关掉的页面又弹出来
|
||||||
|
const handledPages = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await readerAPI.sourceLogin(sourceId)
|
||||||
|
setInfo(data)
|
||||||
|
setForm(initialFormValues(data))
|
||||||
|
setVariable(data.variable ?? '')
|
||||||
|
setError(data.error ?? '')
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '读取登录信息失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [sourceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// 轮询书源登记的待办页面。
|
||||||
|
// java.startBrowserAwait 会阻塞在服务端,前端必须在动作执行期间去取页面,
|
||||||
|
// 否则用户永远看不到「切换线路」「用户后台」这些按钮真正要展示的东西。
|
||||||
|
const pollBrowserPages = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const pages = await readerAPI.browserPending(sourceId)
|
||||||
|
const next = pages.find((p) => !handledPages.current.has(p.id))
|
||||||
|
if (next) setBrowserPage(next)
|
||||||
|
} catch {
|
||||||
|
// 轮询失败不打断正在执行的动作
|
||||||
|
}
|
||||||
|
}, [sourceId])
|
||||||
|
|
||||||
|
// 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。
|
||||||
|
const buttons = useMemo(() => info?.fields.filter((f) => f.type === 'button') ?? [], [info])
|
||||||
|
const inputs = useMemo(() => info?.fields.filter((f) => f.type !== 'button') ?? [], [info])
|
||||||
|
const selects = useMemo(() => info?.fields.filter((f) => f.type === 'toggle' || f.type === 'select') ?? [], [info])
|
||||||
|
|
||||||
|
const runAction = async (action: string, opts: { fieldsOverride?: Record<string, string> } = {}) => {
|
||||||
|
const key = action || '__login__'
|
||||||
|
// 注意用 fieldsOverride 而不是闭包里的 form:select/toggle 的 onPick 里
|
||||||
|
// setForm 是异步的,同一轮事件里读 form 拿到的还是旧值,
|
||||||
|
// 会让书源按旧线路执行(表现为「切了但没生效」)。
|
||||||
|
const fields = opts.fieldsOverride ?? form
|
||||||
|
setRunning(key)
|
||||||
|
setToasts([])
|
||||||
|
setError('')
|
||||||
|
// 动作可能阻塞等待人工操作,期间持续轮询待办页面
|
||||||
|
const poll = window.setInterval(() => void pollBrowserPages(), 600)
|
||||||
|
try {
|
||||||
|
const res = await readerAPI.runSourceLogin(sourceId, { action, fields })
|
||||||
|
const messages = [...(res.toasts ?? [])]
|
||||||
|
if (res.error) messages.push(res.error)
|
||||||
|
// 服务端未注入宿主浏览器时(非登录链路),这里退化为提示 + 可打开的地址
|
||||||
|
for (const b of res.browsers ?? []) {
|
||||||
|
messages.push(`需要浏览器操作:${b.title || ''} ${b.url}`.trim())
|
||||||
|
}
|
||||||
|
setToasts(messages)
|
||||||
|
// 同步回最新登录信息(书源可能在动作里回填字段)
|
||||||
|
if (res.values) setForm((prev) => ({ ...prev, ...res.values }))
|
||||||
|
onLoggedInChange?.(res.logged_in)
|
||||||
|
const nextInfo = await readerAPI.sourceLogin(sourceId)
|
||||||
|
setInfo(nextInfo)
|
||||||
|
setVariable(nextInfo.variable ?? '')
|
||||||
|
if (res.logged_in) toast.success('登录成功')
|
||||||
|
else if (!res.ok) toast.error('登录失败')
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '登录执行失败'
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
window.clearInterval(poll)
|
||||||
|
setRunning('')
|
||||||
|
// 收尾再取一次,避免最后一个待办落在轮询间隙里
|
||||||
|
void pollBrowserPages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBrowserPage = useCallback(() => {
|
||||||
|
setBrowserPage((cur) => {
|
||||||
|
if (cur) handledPages.current.add(cur.id)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveLoginInfo = async () => {
|
||||||
|
try {
|
||||||
|
await readerAPI.saveSourceLoginInfo(sourceId, form)
|
||||||
|
toast.success('已保存登录信息')
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doLogout = async () => {
|
||||||
|
if (!window.confirm('退出登录并清除该源已保存的 Cookie?')) return
|
||||||
|
try {
|
||||||
|
await readerAPI.logoutSource(sourceId)
|
||||||
|
toast.success('已退出登录')
|
||||||
|
onLoggedInChange?.(false)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
toast.error('退出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveVariable = async () => {
|
||||||
|
try {
|
||||||
|
await readerAPI.setSourceVariable(sourceId, variable)
|
||||||
|
toast.success('源变量已保存')
|
||||||
|
const next = await readerAPI.sourceLogin(sourceId)
|
||||||
|
setInfo(next)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openExternal = (url: string) => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已保存 Cookie 条目(domain → cookie),给用户一个登录态的直观依据
|
||||||
|
const cookieEntries = Object.entries(info?.cookies ?? {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
// 页面承载面板打开时不允许点遮罩关闭:书源正阻塞等待用户在这块面板里
|
||||||
|
// 操作,把对话框一起关掉会让回传链路断在半路。
|
||||||
|
onClick={() => {
|
||||||
|
if (!browserPage) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-[var(--app-border)] px-4 py-3">
|
||||||
|
<KeyRound size={16} className="shrink-0 text-brand-600" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-[var(--app-text)]">{sourceName}</p>
|
||||||
|
<p className="text-2xs text-[var(--app-muted)]">
|
||||||
|
{info?.logged_in ? '已登录' : '未登录'}
|
||||||
|
{info?.has_login_js ? ' · 含登录逻辑' : ' · 未配置登录逻辑'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => void load()} title="刷新" className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-16 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 没有 loginUi 的表单时,只有 loginUrl:按 legado 语义当作登录页地址 */}
|
||||||
|
{!info?.fields.length && info?.has_login_js && (
|
||||||
|
<div className="mb-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-muted)]">
|
||||||
|
该书源没有表单(loginUi),登录需要打开网页完成。
|
||||||
|
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="btn-outline ml-2 text-2xs">
|
||||||
|
<ExternalLink size={12} className="mr-1 inline" />
|
||||||
|
打开登录页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表单输入项 */}
|
||||||
|
{inputs.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{inputs.map((f) => (
|
||||||
|
<label key={f.name} className="block">
|
||||||
|
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{f.name}</span>
|
||||||
|
<input
|
||||||
|
type={f.type === 'password' ? 'password' : 'text'}
|
||||||
|
value={form[f.name] ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 选择项(toggle / select):点击后按 action 交由书源处理 */}
|
||||||
|
{selects.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{selects.map((f) => (
|
||||||
|
<SelectField
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
value={form[f.name] ?? f.default ?? ''}
|
||||||
|
disabled={!!running}
|
||||||
|
onPick={(v) => {
|
||||||
|
// 必须把新值显式带进 action:setForm 是异步的,
|
||||||
|
// runAction 读闭包里的 form 会拿到切换前的旧值。
|
||||||
|
const next = { ...form, [f.name]: v }
|
||||||
|
setForm(next)
|
||||||
|
if (f.action) void runAction(f.action, { fieldsOverride: next })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按钮区 */}
|
||||||
|
{buttons.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{buttons.map((f) => (
|
||||||
|
<button
|
||||||
|
key={`${f.name}-${f.action}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!!running}
|
||||||
|
onClick={() => void runAction(f.action ?? '')}
|
||||||
|
className="btn-outline text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running === (f.action || '__login__') && <Loader2 size={12} className="mr-1 inline animate-spin" />}
|
||||||
|
{f.viewName || f.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示与待打开地址 */}
|
||||||
|
{toasts.length > 0 && (
|
||||||
|
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
|
||||||
|
{toasts.filter(Boolean).join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 源变量编辑 */}
|
||||||
|
<div className="mt-4 border-t border-[var(--app-border)] pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVariable((v) => !v)}
|
||||||
|
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
|
||||||
|
>
|
||||||
|
{showVariable ? '▾' : '▸'} 源变量(bookSourceUrl 变量的 JSON)
|
||||||
|
</button>
|
||||||
|
{showVariable && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{info?.variable_comment && (
|
||||||
|
<p className="mb-2 whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-2 text-2xs text-[var(--app-muted)]">
|
||||||
|
{info.variable_comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={variable}
|
||||||
|
onChange={(e) => setVariable(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder='{"线路":"https://v1.example.com"}'
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)] outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => void saveVariable()} className="btn-primary mt-2 text-2xs">
|
||||||
|
<Save size={12} className="mr-1 inline" /> 保存变量
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前 Cookie:登录态的实际凭证 */}
|
||||||
|
{cookieEntries.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
|
||||||
|
<p className="text-2xs font-bold text-[var(--app-muted)]">已保存 Cookie</p>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{cookieEntries.map(([domain, cookie]) => (
|
||||||
|
<p key={domain} className="truncate font-mono text-2xs text-[var(--app-muted)]" title={cookie}>
|
||||||
|
{domain}: {cookie}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* loginUrl 原文(书源作者写的登录脚本) */}
|
||||||
|
{info?.login_js && (
|
||||||
|
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLoginJS((v) => !v)}
|
||||||
|
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
|
||||||
|
>
|
||||||
|
{showLoginJS ? '▾' : '▸'} 登录脚本(loginUrl)
|
||||||
|
</button>
|
||||||
|
{showLoginJS && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="mt-2 mr-2 text-2xs text-brand-600">
|
||||||
|
<ExternalLink size={11} className="mr-1 inline" />
|
||||||
|
若为登录页地址可打开
|
||||||
|
</button>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)]">
|
||||||
|
{info.login_js}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--app-border)] px-4 py-3">
|
||||||
|
<button type="button" onClick={() => void saveLoginInfo()} className="btn-outline text-xs" disabled={loading}>
|
||||||
|
<Save size={13} className="mr-1 inline" /> 仅保存信息
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void doLogout()} className="btn-outline text-xs text-red-500" disabled={loading}>
|
||||||
|
<LogOut size={13} className="mr-1 inline" /> 退出登录
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="btn-primary text-xs">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 书源页面承载面板:startBrowserAwait 会等这里的「完成」把 DOM 回传 */}
|
||||||
|
{browserPage && <BrowserPanel page={browserPage} onClose={closeBrowserPage} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectField 渲染 toggle / select 控件。
|
||||||
|
function SelectField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
field: ReaderLoginField
|
||||||
|
value: string
|
||||||
|
disabled: boolean
|
||||||
|
onPick: (v: string) => void
|
||||||
|
}) {
|
||||||
|
const options = field.chars ?? []
|
||||||
|
if (field.type === 'toggle' || options.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xs text-[var(--app-muted)]">{field.name}</span>
|
||||||
|
<button type="button" disabled={disabled} onClick={() => onPick(value ? '' : '1')} className="btn-outline text-2xs">
|
||||||
|
{value ? '已开启' : '未开启'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{field.name}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onPick(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user