mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
feat(reader): P2 收尾——冒烟 CLI、书架正文链路、替换净化规则
- SmokeChain 结构化全链路冒烟(搜索→详情→目录→正文,分阶段失败定位), SmokeSource 支持不落库直测;Debug 接口改为结构化日志输出 - cmd/reader-smoke:批量书源冒烟 CLI(-file/-url 导入、并发、 各阶段通过率汇总、-json 完整报告) - GetContentForBook:书架维度正文(章节缓存 + 书源 replaceRegex + 用户替换净化规则),前端阅读器切换到该接口 - 替换净化规则 CRUD(后端 + /reader/replace 前端页),用户规则 按 scope/excludeScope 书名过滤、正则带匹配超时防回溯挂死 - rule.ApplyUserReplace 导出(regexp2 MatchTimeout)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user