bug处理

This commit is contained in:
truewhile
2026-10-02 12:02:29 +08:00
parent 5acfa6292a
commit b8648f0049
9 changed files with 563 additions and 17 deletions
+5
View File
@@ -373,6 +373,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
// 「Cannot read properties of null」。空目录统一给 []。
if chapters == nil {
chapters = []reader.TocChapter{}
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
+107
View File
@@ -0,0 +1,107 @@
package reader
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 HTML。
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。
func TestImageRefsInLine(t *testing.T) {
cases := []struct {
name string
line string
want []string
}{
{"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}},
{"双引号 img", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
{"img 没有 src 时跳过", `<img alt="空">`, nil},
{"非 img 的 HTML 片段跳过", `</div>`, nil},
}
for _, c := range cases {
got := imageRefsInLine(c.line)
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("%s: got %q want %q", c.name, got, c.want)
}
}
}
// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址,
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
page := `<html><body><div class="pages">
<img src="/img/1.webp"><img src="/img/2.webp">
</div></body></html>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
srcJSON := fmt.Sprintf(`{
"bookSourceUrl": %q,
"bookSourceName": "漫画测试源",
"bookSourceType": 64,
"ruleContent": { "content": "class.pages@html" }
}`, srv.URL)
sourceID := importTestSource(t, svc, srcJSON, srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "漫画测试源",
BookURL: srv.URL + "/book/1",
Name: "航海王",
Type: 2, // 图片
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"},
}); err != nil {
t.Fatalf("写入章节失败: %v", err)
}
_ = sourceID
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "image" {
t.Fatalf("类型 = %q,期望 image", out.Type)
}
if len(out.Images) != 2 {
t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images)
}
for i, img := range out.Images {
// 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头),
// 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。
if !strings.HasPrefix(img, "/api/reader/media?") {
t.Fatalf("第 %d 张不是代理地址:%q", i, img)
}
encoded := img[strings.Index(img, "u=")+2:]
if end := strings.IndexByte(encoded, '&'); end >= 0 {
encoded = encoded[:end]
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("第 %d 张地址解不开:%q", i, img)
}
want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1)
if string(raw) != want {
t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want)
}
}
}
+83 -4
View File
@@ -1011,8 +1011,20 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL,
return nil, err
}
chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL)
if err != nil {
return nil, err
if err != nil || len(chapters) == 0 {
// 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 gydetail 信封):
// 加书架时只存了 book_url,调用方又把 book_url 当目录地址传进来,规则返回的
// 是书籍详情(没有章节),表现为「目录为空」——漫画就是卡在这里。
// 对齐 legado:目录地址在详情结果里,补走一次详情规则取 tocUrl 再抓,并写回书架。
if detailTocURL := s.deriveTocURLFromDetail(ctx, src, bs, bookURL, tocURL); detailTocURL != "" {
if retried, declared2, retryErr := s.getTocFrom(ctx, src, bs, bookURL, detailTocURL); retryErr == nil && len(retried) > 0 {
chapters, declared, err = retried, declared2, nil
s.persistBookTocURL(ctx, userID, src.SourceURL, bookURL, detailTocURL)
}
}
if err != nil {
return nil, err
}
}
// 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
@@ -1032,6 +1044,40 @@ func latestChapterTitleOf(chapters []TocChapter) string {
return ""
}
// deriveTocURLFromDetail 走一次详情规则,取书源声明的目录地址。
//
// 详情规则没配、或解析出的 tocUrl 与 bookUrl 相同(说明书源就是用书籍页当目录页)时
// 返回空串,调用方按原样处理、不做多余请求。
func (s *ReaderService) deriveTocURLFromDetail(
ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, currentTocURL string,
) string {
detail, err := s.getBookInfoFrom(ctx, src, bs, bookURL)
if err != nil || detail == nil {
return ""
}
derived := strings.TrimSpace(detail.TocURL)
if derived == "" || derived == strings.TrimSpace(bookURL) || derived == strings.TrimSpace(currentTocURL) {
return ""
}
return derived
}
// persistBookTocURL 把详情里解析出的目录地址写回书架记录。
// 只有第一次抓目录要多走一次详情,之后书架、阅读页、更新目录都直接用这个地址。
func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, bookURL, tocURL string) {
if userID == "" || origin == "" || bookURL == "" || tocURL == "" {
return
}
book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
if err != nil || book == nil || book.TocURL == tocURL {
return
}
book.TocURL = tocURL
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)。
@@ -1348,8 +1394,12 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
case 2: // 漫画/图片
out.Type = "image"
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Images = append(out.Images, abs)
// 漫画源的正文规则常直接给 <img src="…"> 的 HTML(一个标签一行)。
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图,所以先抽 src。
for _, ref := range imageRefsInLine(line) {
if abs := rule.GetAbsoluteURL(lastFinalURL, ref); abs != "" {
out.Images = append(out.Images, abs)
}
}
}
out.ImageStyle = SPtr(cr.ImageStyle)
@@ -1359,6 +1409,35 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
return out, nil
}
// imageRefsInLine 取出一行正文里的图片地址。
//
// - 正文规则给的就是地址 → 原样返回;
// - 给的是 <img src="…">(一行可能有多个标签)→ 按标签抽 src,
// legado 的 ruleContent.imageStyle 缺省也是这个语义;
// - 给的是别的 HTML 片段(<div>/</div> 之类,规则返回的 outerHTML 换行后很常见)
// → 跳过,否则会被当成相对地址拼出一个取不回的「图」。
func imageRefsInLine(line string) []string {
matches := imgTagPattern.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
if strings.ContainsRune(line, '<') {
return nil
}
return []string{line}
}
out := make([]string, 0, len(matches))
for _, m := range matches {
// imgTagPattern 的捕获组:整段标签 / 双引号内的 src / 单引号内的 src
ref := strings.TrimSpace(m[2])
if ref == "" {
ref = strings.TrimSpace(m[3])
}
if ref != "" {
out = append(out, ref)
}
}
return out
}
func splitURLLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
+99
View File
@@ -0,0 +1,99 @@
package reader
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// newTocFallbackBook 建一本书架记录:只存 book_url,toc_url 留空,
// 模拟聚合类书源加入书架时的形态。
func newTocFallbackBook(t *testing.T, svc *ReaderService, serverURL string) *model.ReaderBook {
t.Helper()
book := &model.ReaderBook{
UserID: "u1",
Origin: serverURL,
OriginName: "测试源",
BookURL: serverURL + "/book/1",
Name: "斗破苍穹",
}
if err := svc.repo.CreateBook(t.Context(), book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
return book
}
// 聚合类书源(光遇聚合一类)把目录地址放在详情结果里,加书架时只存了 book_url。
// 调用方又习惯把 book_url 当目录地址兜底(前端 toc_url || book_url),于是目录规则
// 拿到的是书籍详情页 → 0 章 → 阅读页显示「目录为空」,漫画根本进不去。
//
// GetToc 必须自己补一次详情、用详情里的 tocUrl 重抓,并把地址写回书架。
func TestGetTocFallsBackToBookInfoTocURL(t *testing.T) {
srv := e2eServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := newTocFallbackBook(t, svc, srv.URL)
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, "")
if err != nil {
t.Fatalf("抓目录失败: %v", err)
}
if len(chapters) != 2 {
t.Fatalf("应回退到详情里的 tocUrl 抓到 2 章,实际 %d 章", len(chapters))
}
// 目录地址要写回书架:之后书架/阅读页/更新目录都不用再走一次详情
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(stored.TocURL, "/book/1/toc") {
t.Fatalf("目录地址未写回: %q", stored.TocURL)
}
}
// 前端会把空 toc_url 兜底成 book_url 传进来,这条路径同样要能拿到目录。
func TestGetTocTreatsBookURLAsMissingTocURL(t *testing.T) {
srv := e2eServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := newTocFallbackBook(t, svc, srv.URL)
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.BookURL)
if err != nil {
t.Fatalf("抓目录失败: %v", err)
}
if len(chapters) != 2 {
t.Fatalf("book_url 兜底的目录地址也应回退到详情,实际 %d 章", len(chapters))
}
}
// 正常源(详情给了独立目录页、调用方也传了)不能被这次改动影响。
func TestGetTocKeepsGivenTocURL(t *testing.T) {
srv := e2eServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := newTocFallbackBook(t, svc, srv.URL)
book.TocURL = srv.URL + "/book/1/toc.html"
if err := svc.repo.UpdateBook(ctx, book); err != nil {
t.Fatal(err)
}
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.TocURL)
if err != nil {
t.Fatalf("抓目录失败: %v", err)
}
if len(chapters) != 2 {
t.Fatalf("应正常抓到 2 章,实际 %d 章", len(chapters))
}
}