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
106 changes: 106 additions & 0 deletions cmd/mark2note/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Flags:
--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)
--collection <name> override Xiaohongshu collection for --publish-xhs/--prepare-xhs
--import-photos import generated PNG files into Apple Photos after export
--import-album <name> Apple Photos album name for imported PNG files
--import-timeout <d> Apple Photos PNG import timeout (default: 2m0s)
Expand Down Expand Up @@ -252,6 +253,7 @@ func parseOptions(args []string) (Options, error) {
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")
fs.StringVar(&opts.XHSCollection, "collection", opts.XHSCollection, "Xiaohongshu collection name for auto publish")
fs.BoolVar(&opts.ImportPhotos, "import-photos", opts.ImportPhotos, "import generated PNG files into Apple Photos after export")
fs.StringVar(&opts.ImportAlbum, "import-album", opts.ImportAlbum, "Apple Photos album name for imported PNG files")
fs.DurationVar(&opts.ImportTimeout, "import-timeout", opts.ImportTimeout, "Apple Photos PNG import timeout")
Expand Down Expand Up @@ -323,6 +325,7 @@ func parseOptions(args []string) (Options, error) {
xhsModeChanged := false
xhsVisibilityChanged := false
xhsScheduleAtChanged := false
xhsCollectionChanged := false
stopBeforeSubmitChanged := false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
Expand Down Expand Up @@ -368,6 +371,8 @@ func parseOptions(args []string) (Options, error) {
xhsVisibilityChanged = true
case "xhs-schedule-at":
xhsScheduleAtChanged = true
case "collection":
xhsCollectionChanged = true
case "stop-before-submit":
stopBeforeSubmitChanged = true
}
Expand All @@ -393,13 +398,17 @@ func parseOptions(args []string) (Options, error) {
opts.XHSModeChanged = xhsModeChanged
opts.XHSVisibilityChanged = xhsVisibilityChanged
opts.XHSScheduleAtChanged = xhsScheduleAtChanged
opts.XHSCollectionChanged = xhsCollectionChanged
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())
}
if (opts.XHSModeChanged || opts.XHSVisibilityChanged || opts.XHSScheduleAtChanged) && !opts.PublishXHS && !opts.PrepareXHS {
return Options{}, fmt.Errorf("--xhs-mode/--xhs-visibility/--xhs-schedule-at require --publish-xhs or --prepare-xhs\n\n%s", usageText())
}
if opts.XHSCollectionChanged && !opts.PublishXHS && !opts.PrepareXHS {
return Options{}, fmt.Errorf("--collection 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 Down Expand Up @@ -809,6 +818,7 @@ func buildAutoPublishXHSOptions(renderOpts Options, renderResult app.Result) (ap
return app.PublishOptions{}, fmt.Errorf("%w: %v", app.ErrReadMarkdown, err)
}
markdown := string(markdownBytes)
electronicPickles := isElectronicPicklesMarkdown(markdown)
title := xhs.MarkdownPublishTitle(markdown, renderOpts.InputPath)
cliOpts := publishXHSCLIOptions{
PublishOptions: app.PublishOptions{
Expand Down Expand Up @@ -847,9 +857,21 @@ func buildAutoPublishXHSOptions(renderOpts Options, renderResult app.Result) (ap
if renderOpts.XHSScheduleAtChanged {
cliOpts.ScheduleAt = strings.TrimSpace(renderOpts.XHSScheduleAt)
}
if renderOpts.XHSCollectionChanged {
cliOpts.Collection = strings.TrimSpace(renderOpts.XHSCollection)
cliOpts.CollectionChanged = true
}
if renderOpts.StopBeforeSubmitChanged {
cliOpts.StopBeforeSubmit = renderOpts.StopBeforeSubmit
}
if electronicPickles {
cliOpts.DeclareOriginal = true
cliOpts.DeclareOriginalChanged = true
cliOpts.OriginalDeclarationType = xhs.OriginalDeclarationTypeAIGenerated
cliOpts.OriginalDeclarationTypeChanged = true
cliOpts.AllowContentCopy = false
cliOpts.AllowContentCopyChanged = true
}
publishOpts := mergePublishXHSDefaults(cliOpts, cfg)
if err := validatePublishXHSOptions(publishOpts); err != nil {
return app.PublishOptions{}, err
Expand Down Expand Up @@ -902,6 +924,9 @@ func buildAutoPublishXHSTopics(cfg config.Config, markdown string, title string,
if len(overrideTags) > 0 {
return xhs.NormalizePublishTopics(overrideTags), nil
}
if topics := buildElectronicPicklesXHSTopics(markdown); len(topics) > 0 {
return topics, nil
}
if cfg.XHS.Publish.TopicGeneration.Enabled == nil || !*cfg.XHS.Publish.TopicGeneration.Enabled {
return nil, fmt.Errorf("xhs publish topic generation is disabled; pass --xhs-tags or enable xhs.publish.topic_generation.enabled")
}
Expand All @@ -916,6 +941,87 @@ func buildAutoPublishXHSTopics(cfg config.Config, markdown string, title string,
return topics, nil
}

func isElectronicPicklesMarkdown(markdown string) bool {
return strings.Contains(markdown, "电子榨菜") && strings.Contains(markdown, "## 小红书卡片")
}

func buildElectronicPicklesXHSTopics(markdown string) []string {
if !isElectronicPicklesMarkdown(markdown) {
return nil
}
topics := []string{"电子榨菜"}
topics = append(topics, electronicPicklesRelatedTopics(markdown)...)
return xhs.NormalizePublishTopics(topics)
}

func electronicPicklesRelatedTopics(markdown string) []string {
sectionStart := strings.Index(markdown, "## 小红书卡片")
if sectionStart < 0 {
return nil
}
lines := strings.Split(markdown[sectionStart:], "\n")
out := make([]string, 0, 4)
seen := map[string]bool{}
add := func(topic string) {
if len(out) >= 4 {
return
}
normalized := xhs.NormalizePublishTopics([]string{topic})
if len(normalized) == 0 || seen[normalized[0]] {
return
}
seen[normalized[0]] = true
out = append(out, normalized[0])
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "## 小红书卡片") {
break
}
if !strings.HasPrefix(trimmed, "### ") {
continue
}
if _, title, ok := strings.Cut(trimmed, "|"); ok {
for _, topic := range electronicPicklesTitleTopics(title) {
add(topic)
}
}
}
return out
}

func electronicPicklesTitleTopics(title string) []string {
title = strings.TrimSpace(title)
if before, _, ok := strings.Cut(title, "(点赞 "); ok {
title = strings.TrimSpace(before)
}
checks := []struct {
marker string
topic string
}{
{"乘风破浪", "乘风破浪的姐姐"},
{"高考", "高考数学"},
{"新一卷", "高考数学"},
{"数学", "高考数学"},
{"特朗普", "特朗普"},
{"盛世天下", "盛世天下"},
{"三国", "三国"},
{"职场", "职场内耗"},
{"妆容", "妆容教程"},
{"发型", "妆容教程"},
{"美食", "美食制作"},
{"日本", "日本生活"},
{"藏獒", "萌宠"},
{"鬼獒", "萌宠"},
}
for _, check := range checks {
if strings.Contains(title, check.marker) {
return []string{check.topic}
}
}
return []string{title}
}

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

func TestBuildAutoPublishXHSTopicsUsesElectronicPicklesTitles(t *testing.T) {
originalBuildPublishTopics := buildPublishTopics
defer func() { buildPublishTopics = originalBuildPublishTopics }()
buildPublishTopics = func(cfg *config.Config, markdown string, title string) ([]string, error) {
t.Fatal("buildPublishTopics called for electronic pickles markdown")
return nil, nil
}
markdown := "# 每日电子榨菜|2026-06-10\n\n## 小红书卡片\n\n" +
"### 第 1 页|歌剧老师锐评《乘风破浪的姐姐2026》四公(上)(点赞 1|收藏 1|投币 1)\n\n" +
"### 第 2 页|2026新一卷数学!难出天际???!(点赞 1|收藏 1|投币 1)\n\n" +
"### 第 3 页|摔麦喷媒体,空降看球赛,特朗普还有多少活要整?(点赞 1|收藏 1|投币 1)\n\n" +
"### 第 4 页|【某幻】《盛世天下》再返深宫,活下来!(点赞 1|收藏 1|投币 1)\n"

got, err := buildAutoPublishXHSTopics(config.Config{}, markdown, "每日电子榨菜", nil)
if err != nil {
t.Fatalf("buildAutoPublishXHSTopics() error = %v", err)
}
want := []string{"电子榨菜", "乘风破浪的姐姐", "高考数学", "特朗普", "盛世天下"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("topics = %#v, want %#v", got, want)
}
}

func TestBuildAutoPublishXHSOptionsDeclaresElectronicPicklesAIGenerated(t *testing.T) {
originalReadFile := readFile
originalLoadConfig := loadConfig
originalBuildPublishTopics := buildPublishTopics
originalBuildPublishTitle := buildPublishTitle
defer func() {
readFile = originalReadFile
loadConfig = originalLoadConfig
buildPublishTopics = originalBuildPublishTopics
buildPublishTitle = originalBuildPublishTitle
}()

imagePath := filepath.Join(t.TempDir(), "p01-cover.png")
if err := os.WriteFile(imagePath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
readFile = func(path string) ([]byte, error) {
return []byte("# 每日电子榨菜|2026-06-10\n\n## 小红书卡片\n\n### 第 1 页|歌剧老师锐评《乘风破浪的姐姐2026》四公(上)(点赞 1|收藏 1|投币 1)\n"), nil
}
loadConfig = func(path string) (*config.Config, error) {
return &config.Config{XHS: config.XHSCfg{Publish: config.XHSPublishCfg{Account: "walker"}}}, nil
}
buildPublishTopics = func(cfg *config.Config, markdown string, title string) ([]string, error) {
t.Fatal("buildPublishTopics called for electronic pickles markdown")
return nil, nil
}
buildPublishTitle = func(cfg *config.Config, markdown string, title string, maxRunes int) (string, error) {
t.Fatal("buildPublishTitle called for title within limit")
return "", nil
}

got, err := buildAutoPublishXHSOptions(Options{InputPath: "article.md", ConfigPath: "configs/config.yaml"}, app.Result{ImagePaths: []string{imagePath}})
if err != nil {
t.Fatalf("buildAutoPublishXHSOptions() error = %v", err)
}
if !got.DeclareOriginal || got.OriginalDeclarationType != "ai_generated" || got.AllowContentCopy {
t.Fatalf("originality flags = declare:%v type:%q copy:%v", got.DeclareOriginal, got.OriginalDeclarationType, got.AllowContentCopy)
}
wantTags := []string{"电子榨菜", "乘风破浪的姐姐"}
if !reflect.DeepEqual(got.Tags, wantTags) {
t.Fatalf("Tags = %#v, want %#v", got.Tags, wantTags)
}
}

func TestRunAutoPublishXHSKeepsTitleWithinXHSLengthLimit(t *testing.T) {
originalReadFile := readFile
originalLoadConfig := loadConfig
Expand Down Expand Up @@ -1582,6 +1649,40 @@ func TestBuildAutoPublishXHSOptionsUsesVisibilityFromCLIOverride(t *testing.T) {
}
}

func TestBuildAutoPublishXHSOptionsUsesCollectionFromCLIOverride(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "article.md")
imagePath := filepath.Join(dir, "p01-cover.png")
configPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(inputPath, []byte("# 自动发布标题\n\n正文"), 0o644); err != nil {
t.Fatalf("WriteFile(input) error = %v", err)
}
if err := os.WriteFile(imagePath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile(image) error = %v", err)
}
if err := os.WriteFile(configPath, []byte(`xhs:
publish:
account: walker
collection: AI科技日报
`), 0o644); err != nil {
t.Fatalf("WriteFile(config) error = %v", err)
}

got, err := buildAutoPublishXHSOptions(Options{
InputPath: inputPath,
ConfigPath: configPath,
XHSTags: []string{"AI代理"},
XHSCollection: "每日电子榨菜",
XHSCollectionChanged: true,
}, app.Result{ImagePaths: []string{imagePath}})
if err != nil {
t.Fatalf("buildAutoPublishXHSOptions() error = %v", err)
}
if got.Collection != "每日电子榨菜" {
t.Fatalf("Collection = %q, want 每日电子榨菜", got.Collection)
}
}

func TestBuildAutoPublishXHSOptionsPassesStopBeforeSubmit(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "article.md")
Expand Down
27 changes: 24 additions & 3 deletions cmd/mark2note/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ func TestUsageTextMentionsAutoPublishXHSFlags(t *testing.T) {
"--xhs-tags <csv> override auto-generated Xiaohongshu topics for --publish-xhs/--prepare-xhs",
"--xhs-mode <mode> override Xiaohongshu publish timing mode for --publish-xhs/--prepare-xhs",
"--xhs-schedule-at <time> override Xiaohongshu schedule time for --publish-xhs/--prepare-xhs",
"--collection <name> override Xiaohongshu collection for --publish-xhs/--prepare-xhs",
"mark2note --input ./example.md --auto-posters --prepare-xhs",
"mark2note publish-xhs --meta ./output/preview/xhs-publish-meta.json",
} {
Expand Down Expand Up @@ -309,13 +310,23 @@ func TestParseOptionsParsesXHSVisibilityWithPrepareXHS(t *testing.T) {
}
}

func TestParseOptionsParsesXHSCollectionWithPrepareXHS(t *testing.T) {
opts, err := parseOptions([]string{"--input", "article.md", "--prepare-xhs", "--collection", "每日电子榨菜"})
if err != nil {
t.Fatalf("parseOptions() error = %v", err)
}
if !opts.PrepareXHS || opts.XHSCollection != "每日电子榨菜" || !opts.XHSCollectionChanged {
t.Fatalf("opts = %#v, want prepare-xhs collection override", opts)
}
}

func TestParseOptionsParsesStopBeforeSubmitWithPublishXHS(t *testing.T) {
opts, err := parseOptions([]string{"--input", "article.md", "--publish-xhs", "--stop-before-submit"})
opts, err := parseOptions([]string{"--input", "article.md", "--publish-xhs", "--collection", "每日电子榨菜", "--stop-before-submit"})
if err != nil {
t.Fatalf("parseOptions() error = %v", err)
}
if !opts.PublishXHS || !opts.StopBeforeSubmit || !opts.StopBeforeSubmitChanged {
t.Fatalf("opts = %#v, want publish-xhs stop-before-submit override", opts)
if !opts.PublishXHS || !opts.StopBeforeSubmit || !opts.StopBeforeSubmitChanged || opts.XHSCollection != "每日电子榨菜" || !opts.XHSCollectionChanged {
t.Fatalf("opts = %#v, want publish-xhs stop-before-submit and collection override", opts)
}
}

Expand Down Expand Up @@ -349,6 +360,16 @@ func TestParseOptionsRejectsXHSTagsWithoutPublishOrPrepareXHS(t *testing.T) {
}
}

func TestParseOptionsRejectsXHSCollectionWithoutPublishOrPrepareXHS(t *testing.T) {
_, err := parseOptions([]string{"--input", "article.md", "--collection", "每日电子榨菜"})
if err == nil {
t.Fatalf("parseOptions() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "--collection requires --publish-xhs or --prepare-xhs") {
t.Fatalf("error = %v", err)
}
}

func TestParseOptionsRejectsPublishXHSWithFromDeck(t *testing.T) {
_, err := parseOptions([]string{"--from-deck", "output/old/deck.json", "--publish-xhs"})
if err == nil {
Expand Down
Loading