diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index fd02333..cea7814 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -187,7 +187,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: Download web/dist uses: actions/download-artifact@v4 diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index 44c7208..937b20f 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -4,10 +4,12 @@ # 产出: # 1. 前端 + 后端编译验证(go vet / go test / go build) # 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) -# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) +# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64), +# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改) # -# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 -# 不打 release tag,只构建带 -beta 标识的产物供测试。 +# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略 +# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、 +# 不打 release tag、不发布 GitHub Release、不部署服务器。 name: Beta Build @@ -22,9 +24,6 @@ permissions: contents: read packages: write -env: - BETA_VERSION_PREFIX: beta - jobs: # ───────────────────────────────────────────────────────────────────────────── # 1) 编译验证 + 多平台二进制产物 @@ -32,17 +31,24 @@ jobs: test-and-build: name: Test & build artifacts runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + binary_version: ${{ steps.version.outputs.binary_version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。 + # beta 只读不自增,避免与 main 的发布流程争抢版本号。 - name: Resolve beta version id: version run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + git fetch origin version || true + BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0") SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" # The binary embeds the SPA (web/dist) via go:embed, so dist must exist # before the Go toolchain touches the web package. @@ -59,7 +65,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: go vet @@ -71,13 +77,13 @@ jobs: - name: go build (host) run: go build ./... - # 多平台可执行文件(嵌入刚构建的 web/dist) + # 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致 - name: Build linux/amd64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server - name: Build linux/arm64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -87,7 +93,8 @@ jobs: if-no-files-found: error # ───────────────────────────────────────────────────────────────────────────── - # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta) + # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA}) + # 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。 # ───────────────────────────────────────────────────────────────────────────── docker-beta: name: Build & push beta Docker image @@ -98,13 +105,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Resolve beta version - id: version - run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") - SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -115,6 +115,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/mebox + tags: | + type=raw,value=beta + type=raw,value=${{ needs.test-and-build.outputs.version }} + - name: Build & push uses: docker/build-push-action@v6 with: @@ -123,11 +132,9 @@ jobs: push: true provenance: false sbom: false - tags: ghcr.io/${{ github.repository_owner }}/mebox:beta - labels: | - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.source=${{ github.repository }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} build-args: | - VERSION=${{ steps.version.outputs.full_version }} + VERSION=${{ needs.test-and-build.outputs.binary_version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e69d2e..e416885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true # The binary embeds the SPA (web/dist) via go:embed, so the dist must exist diff --git a/.gitignore b/.gitignore index 64a5923..d2ca998 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,5 @@ media/ tools/ verify-cache/ verify-data/ -.zcode/ \ No newline at end of file +.zcode/ +.tmp-src diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..79c3199 --- /dev/null +++ b/.zcodeignore @@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/Dockerfile b/Dockerfile index 79edaff..1adce9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Multi-architecture build for MeBox. # # Stage 1 (frontend) : Node 20.19+ -> static SPA bundle -# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) +# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0) # Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user # # Build: @@ -25,7 +25,7 @@ COPY web/ . RUN npm run build # ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) ------------------- -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend ARG TARGETOS ARG TARGETARCH ARG GOPROXY=https://proxy.golang.org,direct diff --git a/README.md b/README.md index b48e5d1..6bb7cbc 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

- Go + Go React Docker License diff --git a/README_EN.md b/README_EN.md index 18ab276..8a828ec 100644 --- a/README_EN.md +++ b/README_EN.md @@ -21,7 +21,7 @@

- Go + Go React Docker License diff --git a/cmd/reader-smoke/main.go b/cmd/reader-smoke/main.go new file mode 100644 index 0000000..c6e6822 --- /dev/null +++ b/cmd/reader-smoke/main.go @@ -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) +} diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..232bbe8 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + MeBox + + + + + + + + + + +

+ + diff --git a/docs/reader-plan.md b/docs/reader-plan.md new file mode 100644 index 0000000..b65621e --- /dev/null +++ b/docs/reader-plan.md @@ -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:` / `##` 正则替换段 +- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}` +- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义) +- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`` 生成 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`(已是依赖)+ `