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
108 changes: 108 additions & 0 deletions cmd/mark2note/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type Options = app.Options

const xhsPublishMetaFilename = "xhs-publish-meta.json"

const (
xhsContentModeNone = "none"
xhsContentModePageTitles = "page-titles"
)

func defaultOptions() Options {
return Options{
OutDir: "output",
Expand Down Expand Up @@ -82,6 +87,7 @@ Flags:
--stop-before-submit for --publish-xhs, prepare Xiaohongshu editor then stop before final submit
--prepare-xhs generate Xiaohongshu publish metadata after render without publishing
--xhs-tags <csv> override auto-generated Xiaohongshu topics for --publish-xhs/--prepare-xhs
--xhs-content-mode <mode> XHS body generation for --publish-xhs/--prepare-xhs (none or page-titles)
--xhs-mode <mode> override Xiaohongshu publish timing mode for --publish-xhs/--prepare-xhs (immediate or schedule)
--xhs-visibility <name> override Xiaohongshu visibility for --publish-xhs/--prepare-xhs (public or only-self)
--xhs-schedule-at <time> override Xiaohongshu schedule time for --publish-xhs/--prepare-xhs (YYYY-MM-DD HH:MM:SS)
Expand Down Expand Up @@ -228,6 +234,17 @@ func isHelpRequest(args []string) bool {
return len(args) == 1 && args[0] == "help"
}

func normalizeXHSContentMode(value string) (string, error) {
switch strings.TrimSpace(value) {
case "", xhsContentModeNone:
return xhsContentModeNone, nil
case xhsContentModePageTitles:
return xhsContentModePageTitles, nil
default:
return "", fmt.Errorf("unsupported value %q", value)
}
}

func parseOptions(args []string) (Options, error) {
opts := defaultOptions()
var xhsTags string
Expand All @@ -250,6 +267,7 @@ func parseOptions(args []string) (Options, error) {
fs.BoolVar(&opts.StopBeforeSubmit, "stop-before-submit", opts.StopBeforeSubmit, "prepare XHS editor then stop before final submit")
fs.BoolVar(&opts.PrepareXHS, "prepare-xhs", opts.PrepareXHS, "generate Xiaohongshu publish metadata after render without publishing")
fs.StringVar(&xhsTags, "xhs-tags", xhsTags, "comma-separated Xiaohongshu topics for auto publish")
fs.StringVar(&opts.XHSContentMode, "xhs-content-mode", opts.XHSContentMode, "Xiaohongshu body content generation mode for auto publish")
fs.StringVar(&opts.XHSMode, "xhs-mode", opts.XHSMode, "Xiaohongshu publish mode for auto publish")
fs.StringVar(&opts.XHSVisibility, "xhs-visibility", opts.XHSVisibility, "Xiaohongshu visibility for auto publish")
fs.StringVar(&opts.XHSScheduleAt, "xhs-schedule-at", opts.XHSScheduleAt, "Xiaohongshu schedule time for auto publish")
Expand Down Expand Up @@ -326,6 +344,7 @@ func parseOptions(args []string) (Options, error) {
xhsVisibilityChanged := false
xhsScheduleAtChanged := false
xhsCollectionChanged := false
xhsContentModeChanged := false
stopBeforeSubmitChanged := false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
Expand Down Expand Up @@ -365,6 +384,8 @@ func parseOptions(args []string) (Options, error) {
liveImportTimeoutChanged = true
case "xhs-tags":
xhsTagsChanged = true
case "xhs-content-mode":
xhsContentModeChanged = true
case "xhs-mode":
xhsModeChanged = true
case "xhs-visibility":
Expand Down Expand Up @@ -399,6 +420,7 @@ func parseOptions(args []string) (Options, error) {
opts.XHSVisibilityChanged = xhsVisibilityChanged
opts.XHSScheduleAtChanged = xhsScheduleAtChanged
opts.XHSCollectionChanged = xhsCollectionChanged
opts.XHSContentModeChanged = xhsContentModeChanged
opts.StopBeforeSubmitChanged = stopBeforeSubmitChanged
if opts.XHSTagsChanged && !opts.PublishXHS && !opts.PrepareXHS {
return Options{}, fmt.Errorf("--xhs-tags requires --publish-xhs or --prepare-xhs\n\n%s", usageText())
Expand All @@ -409,6 +431,9 @@ func parseOptions(args []string) (Options, error) {
if opts.XHSCollectionChanged && !opts.PublishXHS && !opts.PrepareXHS {
return Options{}, fmt.Errorf("--collection requires --publish-xhs or --prepare-xhs\n\n%s", usageText())
}
if opts.XHSContentModeChanged && !opts.PublishXHS && !opts.PrepareXHS {
return Options{}, fmt.Errorf("--xhs-content-mode requires --publish-xhs or --prepare-xhs\n\n%s", usageText())
}
if opts.StopBeforeSubmitChanged && !opts.PublishXHS {
return Options{}, fmt.Errorf("--stop-before-submit requires --publish-xhs\n\n%s", usageText())
}
Expand All @@ -426,6 +451,13 @@ func parseOptions(args []string) (Options, error) {
}
opts.XHSVisibility = string(visibility)
}
if opts.XHSContentModeChanged {
mode, err := normalizeXHSContentMode(opts.XHSContentMode)
if err != nil {
return Options{}, fmt.Errorf("--xhs-content-mode: %w\n\n%s", err, usageText())
}
opts.XHSContentMode = mode
}
return opts, nil
}

Expand Down Expand Up @@ -846,6 +878,11 @@ func buildAutoPublishXHSOptions(renderOpts Options, renderResult app.Result) (ap
return app.PublishOptions{}, err
}
cliOpts.Tags = topics
content, err := buildAutoPublishXHSContent(renderOpts, renderResult)
if err != nil {
return app.PublishOptions{}, fmt.Errorf("build xhs publish content: %w", err)
}
cliOpts.Content = content
if renderOpts.XHSModeChanged {
cliOpts.Mode = strings.TrimSpace(renderOpts.XHSMode)
cliOpts.ModeChanged = true
Expand Down Expand Up @@ -942,6 +979,77 @@ func isElectronicPicklesMarkdown(markdown string) bool {
return strings.Contains(markdown, "电子榨菜") && strings.Contains(markdown, "## 小红书卡片")
}

type xhsContentDeckFile struct {
Pages []xhsContentDeckPage `json:"pages"`
}

type xhsContentDeckPage struct {
Name string `json:"name"`
Variant string `json:"variant"`
Content struct {
Title string `json:"title"`
} `json:"content"`
}

func buildAutoPublishXHSContent(renderOpts Options, renderResult app.Result) (string, error) {
switch strings.TrimSpace(renderOpts.XHSContentMode) {
case "", xhsContentModeNone:
return "", nil
case xhsContentModePageTitles:
return buildXHSPublishContentFromDeck(filepath.Join(renderResult.OutDir, "deck.json"))
default:
return "", fmt.Errorf("unsupported xhs content mode %q", renderOpts.XHSContentMode)
}
}

func buildXHSPublishContentFromDeck(deckPath string) (string, error) {
data, err := os.ReadFile(deckPath)
if err != nil {
return "", err
}
var deck xhsContentDeckFile
if err := json.Unmarshal(data, &deck); err != nil {
return "", err
}
return formatXHSNumberedContent(xhsDeckPageTitlesForPublish(deck)), nil
}

func xhsDeckPageTitlesForPublish(deck xhsContentDeckFile) []string {
titles := make([]string, 0, len(deck.Pages))
for index, page := range deck.Pages {
if isXHSCoverDeckPage(index, page) {
continue
}
title := strings.TrimSpace(page.Content.Title)
if title == "" {
continue
}
titles = append(titles, title)
}
return titles
}

func isXHSCoverDeckPage(index int, page xhsContentDeckPage) bool {
if strings.EqualFold(strings.TrimSpace(page.Variant), "cover") {
return true
}
return index == 0 && strings.Contains(strings.ToLower(strings.TrimSpace(page.Name)), "cover")
}

func formatXHSNumberedContent(titles []string) string {
if len(titles) == 0 {
return ""
}
var b strings.Builder
for index, title := range titles {
if index > 0 {
b.WriteByte('\n')
}
b.WriteString(fmt.Sprintf("%d. %s", index+1, title))
}
return b.String()
}

type xhsPublishMeta struct {
Title string `json:"title"`
Content string `json:"content"`
Expand Down
88 changes: 88 additions & 0 deletions cmd/mark2note/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,16 @@ func TestParseOptionsParsesPrepareXHS(t *testing.T) {
}
}

func TestParseOptionsParsesXHSContentModeWithPrepareXHS(t *testing.T) {
opts, err := parseOptions([]string{"--input", "article.md", "--prepare-xhs", "--xhs-content-mode", "page-titles"})
if err != nil {
t.Fatalf("parseOptions() error = %v", err)
}
if !opts.PrepareXHS || opts.XHSContentMode != "page-titles" || !opts.XHSContentModeChanged {
t.Fatalf("opts = %#v, want prepare-xhs page-titles content mode", opts)
}
}

func TestParseOptionsAllowsXHSTagsWithPrepareXHS(t *testing.T) {
opts, err := parseOptions([]string{"--input", "article.md", "--prepare-xhs", "--xhs-tags", "动漫推荐, 推理番"})
if err != nil {
Expand Down Expand Up @@ -888,6 +898,9 @@ func TestRunPrepareXHSWritesMetadataWithoutPublishing(t *testing.T) {
if !reflect.DeepEqual(meta.Tags, []string{"推理番", "悬疑动画"}) {
t.Fatalf("meta.Tags = %#v", meta.Tags)
}
if meta.Content != "" {
t.Fatalf("meta.Content = %q, want empty by default", meta.Content)
}
if !reflect.DeepEqual(meta.Images, imagePaths) || meta.Account != "walker" || meta.InputPath != inputPath {
t.Fatalf("meta = %#v", meta)
}
Expand All @@ -896,6 +909,81 @@ func TestRunPrepareXHSWritesMetadataWithoutPublishing(t *testing.T) {
}
}

func TestRunPrepareXHSWritesPageTitleContentWhenRequested(t *testing.T) {
originalGeneratePreview := generatePreview
originalPublishXHS := publishXHS
originalLoadConfig := loadConfig
originalBuildPublishTopics := buildPublishTopics
defer func() {
generatePreview = originalGeneratePreview
publishXHS = originalPublishXHS
loadConfig = originalLoadConfig
buildPublishTopics = originalBuildPublishTopics
}()

root := t.TempDir()
outDir := filepath.Join(root, "preview")
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
inputPath := filepath.Join(root, "article.md")
if err := os.WriteFile(inputPath, []byte("# 每日电子榨菜\n\n正文"), 0o644); err != nil {
t.Fatalf("WriteFile(input) error = %v", err)
}
imagePaths := []string{filepath.Join(outDir, "p01-cover.png"), filepath.Join(outDir, "p02-image-caption.png"), filepath.Join(outDir, "p03-image-caption.png")}
for _, imagePath := range imagePaths {
if err := os.WriteFile(imagePath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", imagePath, err)
}
}
deckJSON := `{"pages":[{"name":"p01-cover","variant":"cover","content":{"title":"封面标题"}},{"name":"p02-image-caption","variant":"image-caption","content":{"title":"第一条"}},{"name":"p03-image-caption","variant":"image-caption","content":{"title":"第二条"}},{"name":"p04-image-caption","variant":"image-caption","content":{"title":" "}}]}`
if err := os.WriteFile(filepath.Join(outDir, "deck.json"), []byte(deckJSON), 0o644); err != nil {
t.Fatalf("WriteFile(deck) error = %v", err)
}

generatePreview = func(opts Options) (app.Result, error) {
if opts.XHSContentMode != "page-titles" {
t.Fatalf("XHSContentMode = %q, want page-titles", opts.XHSContentMode)
}
return app.Result{PageCount: 4, OutDir: outDir, ImagePaths: imagePaths}, nil
}
publishXHS = func(app.PublishOptions) (app.PublishResult, error) {
t.Fatalf("publishXHS should not be called by --prepare-xhs")
return app.PublishResult{}, nil
}
topicEnabled := true
loadConfig = func(string) (*config.Config, error) {
return &config.Config{XHS: config.XHSCfg{Publish: config.XHSPublishCfg{
Account: "walker",
TopicGeneration: config.XHSTopicGenerationCfg{Enabled: &topicEnabled},
}}}, nil
}
buildPublishTopics = func(_ *config.Config, md string, title string) ([]string, error) {
return []string{"电子榨菜"}, nil
}

var stdout, stderr bytes.Buffer
code := run([]string{"--input", inputPath, "--out", outDir, "--prepare-xhs", "--xhs-content-mode", "page-titles"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("run() = %d, stdout = %s, stderr = %s", code, stdout.String(), stderr.String())
}
data, err := os.ReadFile(filepath.Join(outDir, xhsPublishMetaFilename))
if err != nil {
t.Fatalf("ReadFile(meta) error = %v", err)
}
var meta xhsPublishMeta
if err := json.Unmarshal(data, &meta); err != nil {
t.Fatalf("Unmarshal(meta) error = %v", err)
}
wantContent := "1. 第一条\n2. 第二条"
if meta.Content != wantContent {
t.Fatalf("meta.Content = %q, want %q", meta.Content, wantContent)
}
if !reflect.DeepEqual(meta.Tags, []string{"电子榨菜"}) {
t.Fatalf("meta.Tags = %#v", meta.Tags)
}
}

func TestRunEnrichPostersWritesManifest(t *testing.T) {
originalEnrichPosterManifest := enrichPosterManifest
defer func() { enrichPosterManifest = originalEnrichPosterManifest }()
Expand Down
2 changes: 2 additions & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type Options struct {
XHSScheduleAtChanged bool
XHSCollection string
XHSCollectionChanged bool
XHSContentMode string
XHSContentModeChanged bool
Animated AnimatedOptions
Live LiveOptions
ImportPhotos bool
Expand Down