diff --git a/internal/app/card_manifest_deck.go b/internal/app/card_manifest_deck.go index 7fbe85a..d0c7b9d 100644 --- a/internal/app/card_manifest_deck.go +++ b/internal/app/card_manifest_deck.go @@ -31,6 +31,8 @@ type cardManifestDocument struct { Period string `json:"period"` Summary []string `json:"summary"` Subtitle string `json:"subtitle"` + Source string `json:"source"` + Badge string `json:"badge"` } type cardManifestItem struct { @@ -95,7 +97,7 @@ func buildCardManifestDeckJSON(data []byte) (string, error) { Badge: cardManifestCoverBadge(manifest.Document), Counter: fmt.Sprintf("1/%d", pageCount), Theme: deck.ThemeDefault, - CTA: cardManifestCoverCTA(manifest.SourceApp), + CTA: cardManifestCoverCTA(manifest), }, Content: deck.PageContent{ Title: cardManifestCoverTitle(manifest.Document), @@ -156,16 +158,24 @@ func cardManifestItemPage(item cardManifestItem, pageNumber int, pageCount int) func cardManifestCoverTitle(document cardManifestDocument) string { title := strings.TrimSpace(document.Title) before, after, ok := strings.Cut(title, "|") - if ok && strings.Contains(before, "电子榨菜") && strings.TrimSpace(after) != "" { + if ok && cardManifestShouldBreakCoverTitle(document, before) && strings.TrimSpace(after) != "" { return strings.TrimSpace(before) + "\n" + strings.TrimSpace(after) } return title } +func cardManifestShouldBreakCoverTitle(document cardManifestDocument, prefix string) bool { + prefix = strings.TrimSpace(prefix) + if strings.Contains(prefix, "电子榨菜") || prefix == "每日热门" || prefix == "每周必看" { + return true + } + return cardManifestIsElectronicPicklesDocument(document) +} + func cardManifestCoverSubtitle(document cardManifestDocument) string { bullets := make([]string, 0, minInt(len(document.Summary), cardManifestCoverSummaryMaxItems)) for _, item := range document.Summary { - item = cardManifestFitText(item, cardManifestCoverSummaryItemMaxRunes) + item = cardManifestCoverSummaryItem(document, item) if item == "" { continue } @@ -180,6 +190,17 @@ func cardManifestCoverSubtitle(document cardManifestDocument) string { return cardManifestFitText(document.Subtitle, cardManifestCoverSummaryItemMaxRunes*cardManifestCoverSummaryMaxItems) } +func cardManifestCoverSummaryItem(document cardManifestDocument, item string) string { + if cardManifestIsElectronicPicklesDocument(document) { + return cardManifestNormalizeText(item) + } + return cardManifestFitText(item, cardManifestCoverSummaryItemMaxRunes) +} + +func cardManifestIsElectronicPicklesDocument(document cardManifestDocument) bool { + return strings.Contains(strings.TrimSpace(document.Title), "电子榨菜") || strings.Contains(strings.TrimSpace(document.Badge), "电子榨菜") +} + func cardManifestBodyMaxRunes(variant string) int { if variant == "image-caption" { return cardManifestImageCaptionBodyMaxRunes @@ -191,6 +212,9 @@ func cardManifestItemBody(item cardManifestItem, maxRunes int) string { if body := cardManifestSectionsBody(item.Sections, maxRunes); body != "" { return body } + if source := cardManifestItemSourceText(item); source != "" { + return cardManifestFitSections(cardManifestNewsBodySections(item, source), maxRunes) + } summary, impact := cardManifestFitSummaryImpact(item.Summary, item.Impact, maxRunes) parts := make([]string, 0, 2) if summary != "" { @@ -202,6 +226,22 @@ func cardManifestItemBody(item cardManifestItem, maxRunes int) string { return strings.Join(parts, "\n\n") } +func cardManifestNewsBodySections(item cardManifestItem, source string) []cardManifestBodySection { + sections := make([]cardManifestBodySection, 0, 3) + sections = appendCardManifestBodySection(sections, "摘要", item.Summary) + sections = appendCardManifestBodySection(sections, "影响", item.Impact) + sections = appendCardManifestBodySection(sections, "来源", source) + return sections +} + +func appendCardManifestBodySection(sections []cardManifestBodySection, label string, body string) []cardManifestBodySection { + body = cardManifestNormalizeText(body) + if body == "" { + return sections + } + return append(sections, cardManifestBodySection{Label: label, Body: body}) +} + func cardManifestSectionsBody(sections []cardManifestSection, maxRunes int) string { normalized := cardManifestNormalizeSections(sections) if len(normalized) == 0 { @@ -467,6 +507,13 @@ func minInt(a int, b int) int { } func cardManifestSourceMarker(item cardManifestItem) string { + if source := cardManifestItemSourceText(item); source != "" { + return "来源:" + source + } + return "来源:未标注" +} + +func cardManifestItemSourceText(item cardManifestItem) string { parts := make([]string, 0, 2) if source := strings.TrimSpace(item.Source); source != "" { parts = append(parts, source) @@ -474,10 +521,7 @@ func cardManifestSourceMarker(item cardManifestItem) string { if published := formatCardManifestPublishedAt(item.PublishedAt); published != "" { parts = append(parts, published) } - if len(parts) == 0 { - return "来源:未标注" - } - return "来源:" + strings.Join(parts, " / ") + return strings.Join(parts, " / ") } func formatCardManifestPublishedAt(value string) string { @@ -499,6 +543,9 @@ func cardManifestItemBadge(item cardManifestItem, pageNumber int) string { } func cardManifestCoverBadge(document cardManifestDocument) string { + if badge := strings.TrimSpace(document.Badge); badge != "" { + return badge + } date := strings.TrimSpace(document.Date) period := strings.TrimSpace(document.Period) switch { @@ -513,8 +560,11 @@ func cardManifestCoverBadge(document cardManifestDocument) string { } } -func cardManifestCoverCTA(sourceApp string) string { - if sourceApp = strings.TrimSpace(sourceApp); sourceApp != "" { +func cardManifestCoverCTA(manifest cardArticleManifest) string { + if source := strings.TrimSpace(manifest.Document.Source); source != "" { + return "来源:" + source + } + if sourceApp := strings.TrimSpace(manifest.SourceApp); sourceApp != "" { return "来源:" + sourceApp } return "来源:manifest" diff --git a/internal/app/service_test.go b/internal/app/service_test.go index ec92232..102cb12 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -266,7 +266,7 @@ func TestServiceGeneratePreviewBuildsCardManifestDeckWithoutAI(t *testing.T) { if imagePage.Variant != "image-caption" || imagePage.Content.Title != "OpenAI 发布新模型" { t.Fatalf("image page = %#v", imagePage) } - if imagePage.Content.Body != "**摘要:** 模型能力提升。\n\n**影响:** 应用开发门槛下降。" { + if imagePage.Content.Body != "**摘要:** 模型能力提升。\n\n**影响:** 应用开发门槛下降。\n\n**来源:** The Verge / 2026-06-16 11:00" { t.Fatalf("image page body = %q", imagePage.Content.Body) } if imagePage.Meta.CTA != "来源:The Verge / 2026-06-16 11:00" { @@ -288,7 +288,7 @@ func TestBuildCardManifestDeckWrapsElectronicPicklesCoverTitle(t *testing.T) { deckJSON, err := buildCardManifestDeckJSON([]byte(`{ "schema_version":"card-article-manifest/v1", "source_app":"vidtrace", - "document":{"title":"每日电子榨菜|2026-06-16"}, + "document":{"title":"每日电子榨菜|2026-06-16","source":"B站每日热门","badge":"每日电子榨菜"}, "items":[ {"id":"BV111","category":"B站热门","title":"第一条","sections":[{"label":"内容概览","body":"第一条内容。"}]}, {"id":"BV222","category":"B站热门","title":"第二条","sections":[{"label":"内容概览","body":"第二条内容。"}]} @@ -304,6 +304,65 @@ func TestBuildCardManifestDeckWrapsElectronicPicklesCoverTitle(t *testing.T) { if got.Pages[0].Content.Title != "每日电子榨菜\n2026-06-16" { t.Fatalf("cover title = %q, want natural line break", got.Pages[0].Content.Title) } + if got.Pages[0].Meta.Badge != "每日电子榨菜" { + t.Fatalf("cover badge = %q, want document badge", got.Pages[0].Meta.Badge) + } + if got.Pages[0].Meta.CTA != "来源:B站每日热门" { + t.Fatalf("cover cta = %q, want source label", got.Pages[0].Meta.CTA) + } +} + +func TestBuildCardManifestDeckWrapsVideoCoverTitleWithBadge(t *testing.T) { + deckJSON, err := buildCardManifestDeckJSON([]byte(`{ + "schema_version":"card-article-manifest/v1", + "source_app":"vidtrace", + "document":{"title":"每日热门|2026-06-16","badge":"每日电子榨菜","source":"B站每日热门","summary":["很长很长的视频标题不应该被省略,因为这是封面下方标题列表"]}, + "items":[ + {"id":"BV111","category":"B站热门","title":"第一条","sections":[{"label":"内容概览","body":"第一条内容。"}]}, + {"id":"BV222","category":"B站热门","title":"第二条","sections":[{"label":"内容概览","body":"第二条内容。"}]} + ] + }`)) + if err != nil { + t.Fatalf("buildCardManifestDeckJSON() error = %v", err) + } + var got deck.Deck + if err := json.Unmarshal([]byte(deckJSON), &got); err != nil { + t.Fatalf("Unmarshal(deckJSON) error = %v", err) + } + if got.Pages[0].Content.Title != "每日热门\n2026-06-16" { + t.Fatalf("cover title = %q, want natural line break", got.Pages[0].Content.Title) + } + if strings.Contains(got.Pages[0].Content.Subtitle, "…") { + t.Fatalf("cover subtitle should not truncate video titles: %q", got.Pages[0].Content.Subtitle) + } +} + +func TestBuildCardManifestDeckKeepsElectronicPicklesCoverSummaryTitles(t *testing.T) { + longTitle := "【萌黄一槽】日向雏田[八极宗师]全技能爆料!步步紧追,崩劲蓄积!完整标题不应该被省略" + manifest := fmt.Sprintf(`{ + "schema_version":"card-article-manifest/v1", + "source_app":"vidtrace", + "document":{"title":"每日电子榨菜|2026-06-16","summary":[%q]}, + "items":[ + {"id":"BV111","category":"B站热门","title":"第一条","sections":[{"label":"内容概览","body":"第一条内容。"}]}, + {"id":"BV222","category":"B站热门","title":"第二条","sections":[{"label":"内容概览","body":"第二条内容。"}]} + ] + }`, longTitle) + deckJSON, err := buildCardManifestDeckJSON([]byte(manifest)) + if err != nil { + t.Fatalf("buildCardManifestDeckJSON() error = %v", err) + } + var got deck.Deck + if err := json.Unmarshal([]byte(deckJSON), &got); err != nil { + t.Fatalf("Unmarshal(deckJSON) error = %v", err) + } + wantSubtitle := "• " + longTitle + if got.Pages[0].Content.Subtitle != wantSubtitle { + t.Fatalf("cover subtitle = %q, want %q", got.Pages[0].Content.Subtitle, wantSubtitle) + } + if strings.Contains(got.Pages[0].Content.Subtitle, "…") { + t.Fatalf("cover subtitle should not truncate video titles: %q", got.Pages[0].Content.Subtitle) + } } func TestServiceGeneratePreviewBuildsNoImageCardManifestItemsAsTextCaption(t *testing.T) { @@ -535,15 +594,15 @@ func TestServiceGeneratePreviewFitsCardManifestTextForNewsCards(t *testing.T) { if utf8.RuneCountInString(imageBody) > cardManifestImageCaptionBodyMaxRunes { t.Fatalf("image-caption body runes = %d, want <= %d", utf8.RuneCountInString(imageBody), cardManifestImageCaptionBodyMaxRunes) } - if !strings.Contains(imageBody, "摘要:") || !strings.Contains(imageBody, "影响:") || !strings.Contains(imageBody, "…") { - t.Fatalf("image-caption body should preserve labels and ellipsis: %q", imageBody) + if !strings.Contains(imageBody, "摘要:") || !strings.Contains(imageBody, "影响:") || !strings.Contains(imageBody, "来源:") || !strings.Contains(imageBody, "The Verge") || !strings.Contains(imageBody, "…") { + t.Fatalf("image-caption body should preserve labels, source, and ellipsis: %q", imageBody) } textBody := r.rendered.Pages[2].Content.Body if utf8.RuneCountInString(textBody) > cardManifestTextCaptionBodyMaxRunes { t.Fatalf("text-caption body runes = %d, want <= %d", utf8.RuneCountInString(textBody), cardManifestTextCaptionBodyMaxRunes) } - if !strings.Contains(textBody, "摘要:") || !strings.Contains(textBody, "影响:") || !strings.Contains(textBody, "…") { - t.Fatalf("text-caption body should preserve labels and ellipsis: %q", textBody) + if !strings.Contains(textBody, "摘要:") || !strings.Contains(textBody, "影响:") || !strings.Contains(textBody, "来源:") || !strings.Contains(textBody, "Hacker News") || !strings.Contains(textBody, "…") { + t.Fatalf("text-caption body should preserve labels, source, and ellipsis: %q", textBody) } }