diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index af16ce1..f0a794b 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -408,6 +408,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc { } // 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。 if info != nil { + // 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始 + // 地址写回,再做下发时的解密代理改写。 + userID := c.GetString(middleware.CtxUserID) + svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL) info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL) } c.JSON(http.StatusOK, info) diff --git a/internal/service/reader/cover_repair_test.go b/internal/service/reader/cover_repair_test.go new file mode 100644 index 0000000..3789c0c --- /dev/null +++ b/internal/service/reader/cover_repair_test.go @@ -0,0 +1,69 @@ +package reader + +import ( + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 上游限流时详情解析不到封面,规则会把页面地址当封面回退。这类记录被存进书架后 +// 会一直显示破图;打开一次详情页应当用真实封面把它修好。 +func TestRepairShelfCoverReplacesFallbackURL(t *testing.T) { + svc, _ := newLoginTestService(t) + ctx := t.Context() + + const origin = "https://api.example.com/book" + const bookURL = "https://api.example.com/book?bookId=1" + const cover = "https://img.example.com/c.jpg" + + book := &model.ReaderBook{ + UserID: "u1", + Origin: origin, + BookURL: bookURL, + Name: "测试书", + CoverURL: bookURL, // 规则取空后回退的产物 + } + if err := svc.repo.CreateBook(ctx, book); err != nil { + t.Fatal(err) + } + + svc.RepairShelfCover(ctx, "u1", origin, bookURL, cover) + + stored, err := svc.repo.GetBook(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if stored.CoverURL != cover { + t.Fatalf("封面应修为 %q,实际 %q", cover, stored.CoverURL) + } +} + +// 已经有有效封面的书不能被动覆盖(可能是换源得到的、或用户自己挑的)。 +func TestRepairShelfCoverKeepsValidCover(t *testing.T) { + svc, _ := newLoginTestService(t) + ctx := t.Context() + + const origin = "https://api.example.com/book" + const bookURL = "https://api.example.com/book?bookId=1" + + book := &model.ReaderBook{ + UserID: "u1", + Origin: origin, + BookURL: bookURL, + Name: "测试书", + CoverURL: "https://img.example.com/old.jpg", + } + if err := svc.repo.CreateBook(ctx, book); err != nil { + t.Fatal(err) + } + + svc.RepairShelfCover(ctx, "u1", origin, bookURL, "https://img.example.com/new.jpg") + + stored, err := svc.repo.GetBook(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if stored.CoverURL != "https://img.example.com/old.jpg" { + t.Fatalf("有效封面不应被覆盖,实际 %q", stored.CoverURL) + } +} diff --git a/internal/service/reader/cover_url_test.go b/internal/service/reader/cover_url_test.go index a76a90b..5418e3c 100644 --- a/internal/service/reader/cover_url_test.go +++ b/internal/service/reader/cover_url_test.go @@ -6,32 +6,48 @@ import "testing" // normalizeCoverURL 必须拦住这类非图片地址,避免前端 显示破图。 func TestNormalizeCoverURL(t *testing.T) { cases := []struct { - name string - in string - want string + name string + in string + baseURL string + want string }{ - {"empty", "", ""}, - {"blank", " ", ""}, - {"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"}, - {"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"}, - {"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"}, - {"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"}, - {"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="}, - {"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"}, + {"empty", "", "", ""}, + {"blank", " ", "", ""}, + {"https", "https://img.example.com/a.jpg", "", "https://img.example.com/a.jpg"}, + {"http", "http://img.example.com/a.jpg", "", "http://img.example.com/a.jpg"}, + {"uppercase scheme", "HTTPS://img.example.com/a.jpg", "", "HTTPS://img.example.com/a.jpg"}, + {"trimmed", " https://img.example.com/a.jpg ", "", "https://img.example.com/a.jpg"}, + {"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "", "data:image/png;base64,iVBORw0KGgo="}, + {"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "", "/api/reader/local/asset?b=1&p=2&s=3"}, { "search params envelope", "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==", "", + "", + }, + {"text data uri", "data:text/plain;base64,aGk=", "", ""}, + {"source name", "光遇聚合", "", ""}, + {"javascript url", "javascript:alert(1)", "", ""}, + { + // 详情规则取不到 $.meta.cover 时按 legado 语义回退成页面地址, + // 这个接口地址不是图片,必须丢掉。 + "fallback to page url", + "https://api.example.com/book?bookId=1", + "https://api.example.com/book?bookId=1", + "", + }, + { + "real cover with page url base", + "https://img.example.com/a.jpg", + "https://api.example.com/book?bookId=1", + "https://img.example.com/a.jpg", }, - {"text data uri", "data:text/plain;base64,aGk=", ""}, - {"source name", "光遇聚合", ""}, - {"javascript url", "javascript:alert(1)", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := normalizeCoverURL(tc.in); got != tc.want { - t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want) + if got := normalizeCoverURL(tc.in, tc.baseURL); got != tc.want { + t.Fatalf("normalizeCoverURL(%q, %q) = %q, want %q", tc.in, tc.baseURL, got, tc.want) } }) } diff --git a/internal/service/reader/rate_limit.go b/internal/service/reader/rate_limit.go index f0585f9..951ff9f 100644 --- a/internal/service/reader/rate_limit.go +++ b/internal/service/reader/rate_limit.go @@ -80,6 +80,10 @@ func parseConcurrentRate(rate string) time.Duration { const ( // maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。 maxRequestAttempts = 5 + // implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。 + // 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右; + // 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。 + implicitRetryAttempts = 3 // requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。 requestRetryDelay = 300 * time.Millisecond ) @@ -98,6 +102,20 @@ func retryableStatus(code int) bool { } } +// implicitRetryableStatus 书源没声明 retry 时,值得自动补一次的失败。 +// +// 只覆盖「上游临时顶不住」的情形:429 是限流/并发上限,502/503/504 是网关或 +// 实例暂时不可用,歇一轮基本会恢复。403(风控)重试同一份请求多半还是 403, +// 500 可能是源站自身 bug(确定性失败),都不在这里浪费一次往返。 +func implicitRetryableStatus(code int) bool { + switch code { + case 429, 502, 503, 504: + return true + default: + return false + } +} + // mediaRetryableStatus 媒体拉取专用的可重试判定。 // // 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于 diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index a8f15f3..eb7807f 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -425,12 +425,25 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, // retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。 // 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。 attempts := 1 - if req.Retry != nil && *req.Retry > 0 { + declaredRetry := req.Retry != nil && *req.Retry > 0 + if declaredRetry { attempts += *req.Retry if attempts > maxRequestAttempts { attempts = maxRequestAttempts } } + // 书源没声明 retry 时,GET 也对上游瞬时失败(429/5xx)自动补一次。 + // 很多聚合源跑在免费/低配后端上,限流是常态(懒人听书源实测约四成请求返回 + // HTTP 429),而 429 的响应体根本不是书源数据,交给规则只会解析出空目录、 + // 空详情。POST 可能有副作用,仍然只按书源声明重试。 + if !declaredRetry && (req.Method == "" || req.Method == http.MethodGet) { + attempts = implicitRetryAttempts + } + // 自动补的一次只认「上游临时顶不住」的状态码,见 implicitRetryableStatus。 + retryAllowed := retryableStatus + if !declaredRetry { + retryAllowed = implicitRetryableStatus + } var ( resp *http.Response data []byte @@ -464,7 +477,7 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, } return "", "", resp.StatusCode, err } - if attempt < attempts && retryableStatus(resp.StatusCode) { + if attempt < attempts && retryAllowed(resp.StatusCode) { if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) { return "", "", resp.StatusCode, nil } @@ -536,6 +549,25 @@ func charsetFromContentType(ct string) string { return strings.Trim(cs, "\"") } +// upstreamStatusError 把书源接口的失败状态码翻译成可读错误。 +// +// 上游限流/过载时返回的响应体不是书源数据:交给规则解析只会得到空目录、空详情, +// 页面就表现成「目录 0 章」而没有任何报错。这里让调用方拿到明确错误, +// 前端才能提示重试,而不是把「抓不到」当成「这本书没有章节」。 +func upstreamStatusError(code int) error { + if code < 400 { + return nil + } + switch code { + case http.StatusTooManyRequests: + return fmt.Errorf("书源接口限流(HTTP 429),请稍后重试") + case http.StatusForbidden: + return fmt.Errorf("书源接口拒绝访问(HTTP 403),可能触发了风控") + default: + return fmt.Errorf("书源接口返回 HTTP %d", code) + } +} + // ─── 规则执行辅助 ─────────────────────────────────────────────────────────── // sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与 @@ -1131,10 +1163,13 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo return nil, req.Unsupported } sess.applyHeaders(req, runner) - body, finalURL, _, err := sess.fetch(req) + body, finalURL, code, err := sess.fetch(req) if err != nil { return nil, err } + if err := upstreamStatusError(code); err != nil { + return nil, err + } ar := sess.newAnalyzer(key, page, body, finalURL) elements, err := ar.GetElements(SPtr(sr.BookList)) @@ -1164,7 +1199,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo WordCount: wordCount, LatestChapter: lastChapter, Intro: intro, - CoverURL: normalizeCoverURL(cover), + CoverURL: normalizeCoverURL(cover, finalURL), BookURL: bookURL, Origins: []SearchOrigin{{ SourceID: src.ID, @@ -1221,11 +1256,20 @@ func firstNonEmpty(vals ...string) string { // 于是封面上会落一串 data: 文本, 按 text/plain 处理必然破图。 // 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面 // (/api/reader/local/asset?...)。 -func normalizeCoverURL(raw string) string { +// +// baseURL 是规则解析时的页面地址(详情页就是书本地址)。取值为空时规则会原样 +// 回退成它,回退结果同样不是图片:比如懒人听书源被上游限流(HTTP 429)时, +// 详情 JSON 里没有 meta.cover,封面就会变成 .../lrts-book?bookId=xxx 这个接口地址。 +// 这类值必须一并丢掉,否则会被存进书架,之后一直显示破图。 +func normalizeCoverURL(raw, baseURL string) string { u := strings.TrimSpace(raw) if u == "" { return "" } + // 规则取空后回退成页面地址的产物:不是图片,也不是用户要的封面。 + if base := strings.TrimSpace(baseURL); base != "" && u == base { + return "" + } lower := strings.ToLower(u) switch { case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"): @@ -1282,10 +1326,13 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo return nil, req.Unsupported } sess.applyHeaders(req, runner) - body, finalURL, _, err := sess.fetch(req) + body, finalURL, code, err := sess.fetch(req) if err != nil { return nil, err } + if err := upstreamStatusError(code); err != nil { + return nil, err + } ar := sess.newAnalyzer("", 0, body, finalURL) sess.applyBookContext(ar, bookURL, book, "", 0) @@ -1319,7 +1366,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo info.Intro = v } if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" { - info.CoverURL = normalizeCoverURL(v) + info.CoverURL = normalizeCoverURL(v, finalURL) } if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" { info.TocURL = v @@ -1569,6 +1616,37 @@ func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, b } } +// RepairShelfCover 用详情页刚解析出的封面修复书架记录。 +// +// 只修「明显坏掉」的旧记录:封面为空,或等于书本地址/书源地址(规则取空后回退 +// baseUrl 的产物)。上游限流(HTTP 429)时详情里没有封面,旧版本会把接口地址 +// 存进书架,之后一直显示破图;打开一次详情页就能自愈。已有有效封面的记录不动, +// 避免覆盖换源或用户数据。 +func (s *ReaderService) RepairShelfCover(ctx context.Context, userID, origin, bookURL, cover string) { + if s.repo == nil || userID == "" || origin == "" || bookURL == "" { + return + } + // 书架记录里的 BookURL 与调用方传入的一致(可能带 ",{...}" 选项段), + // 查找用原值;只在校验封面是否为「回退地址」时用去掉选项段的形态。 + pageURL := tocBookTarget(bookURL) + cover = normalizeCoverURL(cover, pageURL) + if cover == "" { + return + } + book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL) + if err != nil || book == nil { + return + } + current := strings.TrimSpace(book.CoverURL) + if current != "" && current != pageURL && current != strings.TrimSpace(origin) { + return + } + book.CoverURL = cover + if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { + s.log.Warn("reader: 修复书架封面失败", zap.String("book", book.ID), zap.Error(err)) + } +} + // applyTocMeta 把目录阶段得到的信息写回书架记录: // - 书源在规则 JS 里声明的书籍类型(declared >= 0 时); // - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。 @@ -1695,10 +1773,13 @@ func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, b return nil, nil, -1, req.Unsupported } sess.applyHeaders(req, runner) - body, finalURL, _, err := sess.fetch(req) + body, finalURL, code, err := sess.fetch(req) if err != nil { return nil, nil, -1, err } + if err := upstreamStatusError(code); err != nil { + return nil, nil, -1, err + } ar := sess.newAnalyzer("", 0, body, finalURL) sess.applyBookContext(ar, bookURL, nil, "", 0) @@ -1821,7 +1902,7 @@ func (s *ReaderService) ProxyCoverURL(id, rawURL string) string { // // id 为空时按书源 URL 签(搜索结果);传入书籍 ID 时按书签。 func (s *ReaderService) RewriteBookCover(ctx context.Context, id, origin, coverURL string) string { - cover := normalizeCoverURL(coverURL) + cover := normalizeCoverURL(coverURL, "") if cover == "" || !strings.HasPrefix(cover, "http") { return cover } @@ -2082,10 +2163,13 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo return nil, req.Unsupported } sess.applyHeaders(req, runner) - body, finalURL, _, err := sess.fetch(req) + body, finalURL, code, err := sess.fetch(req) if err != nil { return nil, err } + if err := upstreamStatusError(code); err != nil { + return nil, err + } lastFinalURL = finalURL ar := sess.newAnalyzer("", 0, body, finalURL) sess.applyBookContext(ar, bookURL, sess.book, "", 0) @@ -2550,7 +2634,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc BookURL: origin.BookURL, Name: name, Author: author, - CoverURL: normalizeCoverURL(coverURL), + CoverURL: normalizeCoverURL(coverURL, origin.BookURL), Type: origin.OriginType, } if err := s.repo.CreateBook(ctx, book); err != nil { @@ -2611,7 +2695,7 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string, if info != nil { book.TocURL = strings.TrimSpace(info.TocURL) if book.CoverURL == "" { - book.CoverURL = normalizeCoverURL(info.CoverURL) + book.CoverURL = normalizeCoverURL(info.CoverURL, target) } if info.LatestChapter != "" { book.LatestChapterTitle = info.LatestChapter diff --git a/internal/service/reader/upstream_retry_test.go b/internal/service/reader/upstream_retry_test.go new file mode 100644 index 0000000..3c63c29 --- /dev/null +++ b/internal/service/reader/upstream_retry_test.go @@ -0,0 +1,82 @@ +package reader + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// 上游限流(HTTP 429)在免费/低配后端上是常态。书源没声明 retry 时, +// GET 也会自动补一次:第一次 429、第二次正常时,详情必须能拿到数据。 +func TestGetBookInfoRetriesUpstream429(t *testing.T) { + upstream := e2eServer() + defer upstream.Close() + + var mu sync.Mutex + hits := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + hits[r.URL.Path]++ + n := hits[r.URL.Path] + mu.Unlock() + if n == 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`)) + return + } + resp, err := http.Get(upstream.URL + r.URL.RequestURI()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + w.WriteHeader(resp.StatusCode) + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + _, _ = w.Write(buf[:n]) + } + if err != nil { + return + } + } + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + + info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1") + if err != nil { + t.Fatalf("第一次 429、重试后应成功: %v", err) + } + if info.Name == "" { + t.Fatalf("重试后详情应有书名,实际为空") + } +} + +// 持续限流时必须报错,而不是把 429 的响应体解析成「空目录 / 0 章」。 +func TestGetTocReportsUpstream429(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + + _, err := svc.GetToc(t.Context(), "u1", sourceID, srv.URL, srv.URL+"/book/1", srv.URL+"/book/1") + if err == nil { + t.Fatalf("持续 429 时应返回错误,而不是空目录") + } + if !strings.Contains(err.Error(), "429") { + t.Fatalf("错误信息应包含 429,实际: %v", err) + } +} diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx index 83e92f6..4d7cc66 100644 --- a/web/src/pages/reader/ReaderBookPage.tsx +++ b/web/src/pages/reader/ReaderBookPage.tsx @@ -23,6 +23,9 @@ export default function ReaderBookPage() { const [info, setInfo] = useState(null) const [infoError, setInfoError] = useState('') const [chapters, setChapters] = useState(null) + // 目录拉取失败的原因(书源限流/失联)。有它时不再把空列表显示成「0 章」, + // 否则用户以为这本书真的没有目录。 + const [tocError, setTocError] = useState('') const [shelfBook, setShelfBook] = useState(null) const [busy, setBusy] = useState(false) // 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录 @@ -46,19 +49,48 @@ export default function ReaderBookPage() { useEffect(loadInfo, [bookURL]) // 书架状态 + 目录 + // + // 两个请求都依赖书源上游,而聚合源的免费后端经常限流(HTTP 429)。目录失败时 + // 不再静默显示「0 章」:先退回书架里的目录缓存(至少能看章节、点开阅读), + // 没有缓存才把失败原因交给目录区展示,用户点「重试」即可。 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([])) + let alive = true + setChapters(null) + setTocError('') + ;(async () => { + let shelf: ReaderBook | null = null + try { + const books = await readerAPI.listBooks() + shelf = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null + } catch { + // 书架读取失败不影响详情页展示 + } + if (!alive) return + setShelfBook(shelf) + try { + const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL }) + if (!alive) return + setChapters(list) + } catch (e) { + if (!alive) return + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录加载失败' + if (shelf) { + const cached = await readerAPI.listChapters(shelf.id).catch(() => []) + if (!alive) return + if (cached.length > 0) { + setChapters(cached.map((c) => ({ ...c, update_time: '' }))) + setTocError(`${msg}(已显示书架缓存)`) + return + } + } + setChapters([]) + setTocError(msg) + } + })() + return () => { + alive = false + } }, [bookURL, sourceURL]) // 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让 @@ -77,7 +109,10 @@ export default function ReaderBookPage() { if (!tocURL || tocURL === bookURL) return readerAPI .toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL }) - .then(setChapters) + .then((list) => { + setChapters(list) + setTocError('') + }) .catch(() => undefined) }, [info?.toc_url, sourceURL, bookURL]) @@ -142,9 +177,12 @@ export default function ReaderBookPage() { try { const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL }) setChapters(list) + setTocError('') toast.success(`目录已刷新,共 ${list.length} 章`) - } catch { - toast.error('目录刷新失败') + } catch (e) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录刷新失败' + setTocError(msg) + toast.error(msg) } finally { setBusy(false) } @@ -256,7 +294,7 @@ export default function ReaderBookPage() {

)}

- 最新:{info?.latest_chapter || '加载中…'} + 最新:{info?.latest_chapter || (info ? '—' : '加载中…')}

书源:{originName || sourceURL}

{(info?.intro || infoError) && ( @@ -264,7 +302,15 @@ export default function ReaderBookPage() {

{infoError || info?.intro}

- {!infoError && ( + {infoError ? ( + + ) : ( + + )} {chapters === null ? (