Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions internal/app/card_manifest_deck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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 != "" {
Expand All @@ -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 {
Expand Down Expand Up @@ -467,17 +507,21 @@ 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)
}
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 {
Expand All @@ -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 {
Expand All @@ -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"
Expand Down
71 changes: 65 additions & 6 deletions internal/app/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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":"第二条内容。"}]}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down