mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -2074,11 +2074,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b
|
||||
|
||||
// TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。
|
||||
type TocRefreshResult struct {
|
||||
Total int `json:"total"` // 参与刷新的网络书籍数
|
||||
Updated int `json:"updated"` // 章数变多的书(有新章节)
|
||||
Failed int `json:"failed"` // 抓取或写入失败的书
|
||||
Total int `json:"total"` // 参与刷新的网络书籍数
|
||||
Updated int `json:"updated"` // 章数变多的书(有新章节)
|
||||
Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新)
|
||||
Failed int `json:"failed"` // 抓取或写入失败的书
|
||||
}
|
||||
|
||||
// tocRefreshStatus 单本书目录刷新的结果。
|
||||
type tocRefreshStatus int
|
||||
|
||||
const (
|
||||
// tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。
|
||||
tocRefreshUnchanged tocRefreshStatus = iota
|
||||
// tocRefreshUpdated 抓取成功且章数变多,有新章节。
|
||||
tocRefreshUpdated
|
||||
// tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。
|
||||
tocRefreshFailed
|
||||
)
|
||||
|
||||
// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。
|
||||
const refreshTocConcurrency = 4
|
||||
|
||||
@@ -2087,6 +2100,8 @@ const refreshTocConcurrency = 4
|
||||
// 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新
|
||||
// latest_chapter_time。本地书籍与没有书源信息的书籍跳过。
|
||||
// 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。
|
||||
// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节),
|
||||
// 混在一起会让人误以为书源坏了。
|
||||
func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) {
|
||||
books, err := s.repo.ListBooks(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -2118,43 +2133,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To
|
||||
defer func() { <-sem }()
|
||||
bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout)
|
||||
defer cancel()
|
||||
if s.refreshBookToc(bookCtx, userID, b) {
|
||||
mu.Lock()
|
||||
res.Updated++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
status := s.refreshBookToc(bookCtx, userID, b)
|
||||
mu.Lock()
|
||||
res.Failed++
|
||||
mu.Unlock()
|
||||
defer mu.Unlock()
|
||||
switch status {
|
||||
case tocRefreshUpdated:
|
||||
res.Updated++
|
||||
case tocRefreshUnchanged:
|
||||
res.Unchanged++
|
||||
default:
|
||||
res.Failed++
|
||||
}
|
||||
}(b)
|
||||
}
|
||||
wg.Wait()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。
|
||||
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool {
|
||||
// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。
|
||||
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus {
|
||||
before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID})
|
||||
chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL)
|
||||
if err != nil || len(chapters) == 0 {
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
if err != nil {
|
||||
s.logTocRefreshFailure("reader: 更新目录失败", book, err)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
// 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。
|
||||
// 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。
|
||||
if len(chapters) == 0 {
|
||||
s.logTocRefreshFailure("reader: 更新目录为空", book, nil)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
inputs := make([]ChapterInput, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume})
|
||||
}
|
||||
if err := s.SaveChapters(ctx, book.ID, inputs); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
s.logTocRefreshFailure("reader: 更新目录写入失败", book, err)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
beforeCount := before[book.ID]
|
||||
return beforeCount > 0 && len(chapters) > beforeCount
|
||||
// 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。
|
||||
if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount {
|
||||
return tocRefreshUpdated
|
||||
}
|
||||
return tocRefreshUnchanged
|
||||
}
|
||||
|
||||
// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自
|
||||
// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。
|
||||
func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("book", book.ID),
|
||||
zap.String("name", book.Name),
|
||||
zap.String("origin", book.OriginName),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.log.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// ListReplaceRules 用户替换规则列表。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件:「更新目录」的结果分档回归测试。
|
||||
//
|
||||
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
|
||||
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
|
||||
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
|
||||
|
||||
// refreshTocForTest 跑一次「更新目录」并返回汇总。
|
||||
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
|
||||
t.Helper()
|
||||
res, err := svc.RefreshBooksToc(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("更新目录失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
|
||||
func newRefreshTocServer(tocHandler *http.HandlerFunc) *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, "/book/1/toc"):
|
||||
(*tocHandler)(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
|
||||
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
})
|
||||
srv := newRefreshTocServer(&tocHandler)
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
TocURL: srv.URL + "/book/1/toc.html",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
|
||||
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
|
||||
}
|
||||
|
||||
// 2) 站点多出一章:这才算「有新章节」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Replace(
|
||||
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
|
||||
)))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
|
||||
}
|
||||
|
||||
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
|
||||
// 这种静默失败必须记失败,不能报成「已是最新」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
|
||||
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
|
||||
}
|
||||
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
|
||||
chapters, err = svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
|
||||
}
|
||||
|
||||
// 4) 站点 404:抓取失败。
|
||||
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 {
|
||||
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
|
||||
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
|
||||
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
|
||||
for _, b := range []*model.ReaderBook{local, sourceless} {
|
||||
if err := svc.repo.CreateBook(ctx, b); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user