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
169 changes: 160 additions & 9 deletions internal/app/card_manifest_deck.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,33 @@ type cardManifestDocument struct {
}

type cardManifestItem struct {
ID string `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
Summary string `json:"summary"`
Impact string `json:"impact"`
Source string `json:"source"`
PublishedAt string `json:"published_at"`
URL string `json:"url"`
Image cardManifestImage `json:"image"`
ID string `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
Summary string `json:"summary"`
Impact string `json:"impact"`
Sections []cardManifestSection `json:"sections"`
Source string `json:"source"`
PublishedAt string `json:"published_at"`
URL string `json:"url"`
Image cardManifestImage `json:"image"`
}

type cardManifestSection struct {
Label string `json:"label"`
Body string `json:"body"`
}

type cardManifestImage struct {
Src string `json:"src"`
Alt string `json:"alt"`
}

type cardManifestBodySection struct {
Label string
Body string
}

func (s Service) buildCardManifestDeckJSON(path string) (string, error) {
data, err := s.effectiveReadFile()(path)
if err != nil {
Expand Down Expand Up @@ -168,6 +179,9 @@ func cardManifestBodyMaxRunes(variant string) int {
}

func cardManifestItemBody(item cardManifestItem, maxRunes int) string {
if body := cardManifestSectionsBody(item.Sections, maxRunes); body != "" {
return body
}
summary, impact := cardManifestFitSummaryImpact(item.Summary, item.Impact, maxRunes)
parts := make([]string, 0, 2)
if summary != "" {
Expand All @@ -179,6 +193,143 @@ func cardManifestItemBody(item cardManifestItem, maxRunes int) string {
return strings.Join(parts, "\n\n")
}

func cardManifestSectionsBody(sections []cardManifestSection, maxRunes int) string {
normalized := cardManifestNormalizeSections(sections)
if len(normalized) == 0 {
return ""
}
return cardManifestFitSections(normalized, maxRunes)
}

func cardManifestNormalizeSections(sections []cardManifestSection) []cardManifestBodySection {
normalized := make([]cardManifestBodySection, 0, len(sections))
for _, section := range sections {
body := cardManifestNormalizeText(section.Body)
if body == "" {
continue
}
normalized = append(normalized, cardManifestBodySection{
Label: cardManifestNormalizeText(section.Label),
Body: body,
})
}
return normalized
}

func cardManifestFitSections(sections []cardManifestBodySection, maxRunes int) string {
if len(sections) == 0 || maxRunes <= 0 {
return ""
}
text := cardManifestSectionsText(sections)
if runeCount(text) <= maxRunes {
return text
}
fixedRunes := cardManifestSectionsFixedRuneCount(sections)
contentBudget := maxRunes - fixedRunes
if contentBudget < len(sections) {
return cardManifestFitText(text, maxRunes)
}
budgets := cardManifestSectionBudgets(sections, contentBudget)
fitted := make([]cardManifestBodySection, 0, len(sections))
for i, section := range sections {
body := cardManifestFitText(section.Body, budgets[i])
if body == "" {
continue
}
fitted = append(fitted, cardManifestBodySection{Label: section.Label, Body: body})
}
text = cardManifestSectionsText(fitted)
for runeCount(text) > maxRunes {
index := cardManifestLongestSectionBodyIndex(fitted)
if index < 0 {
return cardManifestFitText(text, maxRunes)
}
nextBudget := runeCount(fitted[index].Body) - 1
if nextBudget <= 0 {
fitted = append(fitted[:index], fitted[index+1:]...)
if len(fitted) == 0 {
return ""
}
} else {
fitted[index].Body = cardManifestFitText(fitted[index].Body, nextBudget)
}
text = cardManifestSectionsText(fitted)
}
return text
}

func cardManifestSectionsText(sections []cardManifestBodySection) string {
parts := make([]string, 0, len(sections))
for _, section := range sections {
parts = append(parts, cardManifestSectionText(section))
}
return strings.Join(parts, "\n\n")
}

func cardManifestSectionText(section cardManifestBodySection) string {
if section.Label == "" {
return section.Body
}
return section.Label + ":" + section.Body
}

func cardManifestSectionsFixedRuneCount(sections []cardManifestBodySection) int {
count := 0
for i, section := range sections {
if i > 0 {
count += runeCount("\n\n")
}
if section.Label != "" {
count += runeCount(section.Label + ":")
}
}
return count
}

func cardManifestSectionBudgets(sections []cardManifestBodySection, contentBudget int) []int {
budgets := make([]int, len(sections))
remaining := contentBudget
for remaining > 0 {
active := make([]int, 0, len(sections))
for i, section := range sections {
if budgets[i] < runeCount(section.Body) {
active = append(active, i)
}
}
if len(active) == 0 {
break
}
share := remaining / len(active)
if share < 1 {
share = 1
}
for _, index := range active {
need := runeCount(sections[index].Body) - budgets[index]
add := minInt(share, need)
add = minInt(add, remaining)
budgets[index] += add
remaining -= add
if remaining == 0 {
break
}
}
}
return budgets
}

func cardManifestLongestSectionBodyIndex(sections []cardManifestBodySection) int {
index := -1
maxRunes := 0
for i, section := range sections {
bodyRunes := runeCount(section.Body)
if bodyRunes > maxRunes {
index = i
maxRunes = bodyRunes
}
}
return index
}

func cardManifestFitSummaryImpact(summary string, impact string, maxRunes int) (string, string) {
summary = cardManifestNormalizeText(summary)
impact = cardManifestNormalizeText(impact)
Expand Down
140 changes: 140 additions & 0 deletions internal/app/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,146 @@ func TestServiceGeneratePreviewBuildsNoImageCardManifestItemsAsTextCaption(t *te
}
}

func TestServiceGeneratePreviewBuildsCardManifestSectionsWithoutSummaryImpactLabels(t *testing.T) {
root := t.TempDir()
manifestPath := filepath.Join(root, "manifest.json")
manifest := `{
"schema_version":"card-article-manifest/v1",
"source_app":"vidtrace",
"document":{"title":"每日电子榨菜"},
"items":[
{
"id":"BV111",
"category":"B站热门",
"title":"海底生存实况",
"summary":"不应该出现的摘要",
"impact":"不应该出现的影响",
"source":"游戏人阿管",
"sections":[
{"label":"内容概览","body":"这是一段海底生存游戏实况解说。"},
{"label":"互动数据","body":"点赞 12,345|收藏 6,789|投币 2,345"}
]
},
{"id":"BV222","category":"B站热门","title":"第二条视频","sections":[{"label":"内容概览","body":"第二条内容。"}]}
]
}`
if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
r := &fakeRenderer{}
svc := Service{
LoadConfig: func(string) (*config.Config, error) {
return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil
},
ReadFile: func(path string) ([]byte, error) {
if path == "article.md" {
return []byte("# ignored"), nil
}
return os.ReadFile(path)
},
NewRenderer: func(Options) DeckRenderer { return r },
}

_, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath})
if err != nil {
t.Fatalf("GeneratePreview() error = %v", err)
}
body := r.rendered.Pages[1].Content.Body
want := "内容概览:这是一段海底生存游戏实况解说。\n\n互动数据:点赞 12,345|收藏 6,789|投币 2,345"
if body != want {
t.Fatalf("section body = %q, want %q", body, want)
}
for _, forbidden := range []string{"摘要:", "影响:", "不应该出现"} {
if strings.Contains(body, forbidden) {
t.Fatalf("section body should not contain %q: %q", forbidden, body)
}
}
}

func TestServiceGeneratePreviewFitsCardManifestSections(t *testing.T) {
root := t.TempDir()
manifestPath := filepath.Join(root, "manifest.json")
longOverview := strings.Repeat("这是一段很长的视频内容概览,用来说明视频主题、叙事结构和主要看点。", 10)
longKeyPoints := strings.Repeat("这是一段很长的关键看点,用来覆盖反转、冲突、制作细节和观看理由。", 8)
manifest := fmt.Sprintf(`{
"schema_version":"card-article-manifest/v1",
"source_app":"vidtrace",
"document":{"title":"每日电子榨菜"},
"items":[
{"id":"BV111","category":"B站热门","title":"配图视频","source":"游戏人阿管","image":{"src":"https://example.com/cover.jpg","alt":"视频封面"},"sections":[{"label":"内容概览","body":%q},{"label":"关键看点","body":%q},{"label":"互动数据","body":"点赞 12,345|收藏 6,789|投币 2,345"}]},
{"id":"BV222","category":"B站热门","title":"无图视频","source":"游戏人阿管","sections":[{"label":"内容概览","body":%q},{"label":"关键看点","body":%q},{"label":"互动数据","body":"点赞 22,345|收藏 7,789|投币 3,345"}]}
]
}`, longOverview, longKeyPoints, longOverview, longKeyPoints)
if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
r := &fakeRenderer{}
svc := Service{
LoadConfig: func(string) (*config.Config, error) {
return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil
},
ReadFile: func(path string) ([]byte, error) {
if path == "article.md" {
return []byte("# ignored"), nil
}
return os.ReadFile(path)
},
NewRenderer: func(Options) DeckRenderer { return r },
}

_, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath})
if err != nil {
t.Fatalf("GeneratePreview() error = %v", err)
}
imageBody := r.rendered.Pages[1].Content.Body
if utf8.RuneCountInString(imageBody) > cardManifestImageCaptionBodyMaxRunes {
t.Fatalf("image-caption section body runes = %d, want <= %d", utf8.RuneCountInString(imageBody), cardManifestImageCaptionBodyMaxRunes)
}
for _, want := range []string{"内容概览:", "关键看点:", "互动数据:", "…"} {
if !strings.Contains(imageBody, want) {
t.Fatalf("image-caption section body should contain %q: %q", want, imageBody)
}
}
textBody := r.rendered.Pages[2].Content.Body
if utf8.RuneCountInString(textBody) > cardManifestTextCaptionBodyMaxRunes {
t.Fatalf("text-caption section body runes = %d, want <= %d", utf8.RuneCountInString(textBody), cardManifestTextCaptionBodyMaxRunes)
}
for _, want := range []string{"内容概览:", "关键看点:", "互动数据:", "…"} {
if !strings.Contains(textBody, want) {
t.Fatalf("text-caption section body should contain %q: %q", want, textBody)
}
}
}

func TestServiceGeneratePreviewReturnsErrorForUnknownCardManifestSectionField(t *testing.T) {
root := t.TempDir()
manifestPath := filepath.Join(root, "manifest.json")
manifest := `{"schema_version":"card-article-manifest/v1","document":{"title":"每日电子榨菜"},"items":[{"id":"BV111","title":"海底生存实况","sections":[{"label":"内容概览","body":"正文","unknown":"bad"}]}]}`
if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
svc := Service{
LoadConfig: func(string) (*config.Config, error) {
return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil
},
ReadFile: func(path string) ([]byte, error) {
if path == "article.md" {
return []byte("# ignored"), nil
}
return os.ReadFile(path)
},
NewRenderer: func(Options) DeckRenderer { return &fakeRenderer{} },
}

_, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath})
if err == nil {
t.Fatalf("GeneratePreview() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "parse card manifest json") || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("GeneratePreview() error = %v", err)
}
}

func TestServiceGeneratePreviewFitsCardManifestTextForNewsCards(t *testing.T) {
root := t.TempDir()
manifestPath := filepath.Join(root, "manifest.json")
Expand Down