diff --git a/cmd/mark2note/main.go b/cmd/mark2note/main.go index fc69243..b4f01ae 100644 --- a/cmd/mark2note/main.go +++ b/cmd/mark2note/main.go @@ -1454,6 +1454,9 @@ func printPublishXHSError(stderr io.Writer, err error) { } func printPublishXHSResult(stdout io.Writer, result app.PublishResult) int { + for _, warning := range nonEmptyStrings(result.Result.Warnings) { + fmt.Fprintf(stdout, "xhs publish warning: %s\n", strings.Join(strings.Fields(warning), " ")) + } if result.Result.StoppedBeforeSubmit { fmt.Fprintln(stdout, "xiaohongshu publish prepared; stopped before submit") fmt.Fprintf(stdout, "account: %s\n", result.Request.Account) diff --git a/cmd/mark2note/main_publish_test.go b/cmd/mark2note/main_publish_test.go index e360e1f..5fdb272 100644 --- a/cmd/mark2note/main_publish_test.go +++ b/cmd/mark2note/main_publish_test.go @@ -1251,6 +1251,31 @@ func TestRunPublishXHSParsesStandardMediaFlags(t *testing.T) { } } +func TestRunPublishXHSPrintsPublishWarnings(t *testing.T) { + originalPublishXHS := publishXHS + defer func() { publishXHS = originalPublishXHS }() + publishXHS = func(opts app.PublishOptions) (app.PublishResult, error) { + return app.PublishResult{ + Request: xhs.PublishRequest{Account: opts.Account}, + Result: xhs.PublishResult{ + Mode: xhs.PublishModeOnlySelf, + MediaKind: xhs.MediaKindStandard, + OnlySelfPublished: true, + Warnings: []string{`topic "财经新闻" skipped after 3 attempts`}, + }, + }, nil + } + + var stdout, stderr bytes.Buffer + code := run([]string{"publish-xhs", "--account", "creator-a", "--title", "标题", "--content", "正文", "--images", "cover.jpg"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `xhs publish warning: topic "财经新闻" skipped after 3 attempts`) { + t.Fatalf("stdout = %q, want xhs publish warning", stdout.String()) + } +} + func TestRunPublishXHSPrintsLoginGuidance(t *testing.T) { originalPublishXHS := publishXHS defer func() { publishXHS = originalPublishXHS }() diff --git a/internal/xhs/browser_session.go b/internal/xhs/browser_session.go index f8c1c10..8a387ac 100644 --- a/internal/xhs/browser_session.go +++ b/internal/xhs/browser_session.go @@ -886,8 +886,9 @@ func (p *rodPage) effectiveTimeouts() rodPageTimeouts { } type rodPage struct { - page *rod.Page - timeouts rodPageTimeouts + page *rod.Page + timeouts rodPageTimeouts + publishWarnings []string } func (p *rodPage) Navigate(url string) error { diff --git a/internal/xhs/orchestrator.go b/internal/xhs/orchestrator.go index 0b5c089..455dae4 100644 --- a/internal/xhs/orchestrator.go +++ b/internal/xhs/orchestrator.go @@ -75,6 +75,7 @@ func (o *Orchestrator) Publish(ctx context.Context, request PublishRequest) (res publishDone := timing.Stage("xhs.Orchestrator.publish_only_self", timing.Field("media", request.MediaKind)) err = o.publishOnlySelf(ctx, page, request) publishDone(err) + result.Warnings = publishPageWarnings(page) if err != nil { result.BrowserKept = true return result, err @@ -86,6 +87,7 @@ func (o *Orchestrator) Publish(ctx context.Context, request PublishRequest) (res publishDone := timing.Stage("xhs.Orchestrator.publish_scheduled", timing.Field("media", request.MediaKind)) err = o.publishScheduled(ctx, page, request) publishDone(err) + result.Warnings = publishPageWarnings(page) if err != nil { result.BrowserKept = true return result, err @@ -108,6 +110,14 @@ func (o *Orchestrator) Publish(ctx context.Context, request PublishRequest) (res return result, nil } +func publishPageWarnings(page PublishPage) []string { + provider, ok := page.(interface{ PublishWarnings() []string }) + if !ok { + return nil + } + return append([]string(nil), provider.PublishWarnings()...) +} + func (o *Orchestrator) publishOnlySelf(ctx context.Context, page PublishPage, request PublishRequest) error { if request.MediaKind == MediaKindLive { if err := page.Open(ctx); err != nil { diff --git a/internal/xhs/orchestrator_test.go b/internal/xhs/orchestrator_test.go index a4ff05f..f25c0a8 100644 --- a/internal/xhs/orchestrator_test.go +++ b/internal/xhs/orchestrator_test.go @@ -52,6 +52,20 @@ func TestOrchestratorRunsStandardOnlySelfFlow(t *testing.T) { } } +func TestOrchestratorReturnsPagePublishWarnings(t *testing.T) { + page := &fakePublishPage{publishWarnings: []string{`topic "财经新闻" skipped after 3 attempts`}} + session := &fakeBrowserSession{page: page} + request := PublishRequest{Account: "writer", Title: "标题", Content: "正文", Mode: PublishModeOnlySelf, MediaKind: MediaKindStandard, ImagePaths: []string{"cover.jpg"}} + result, err := NewOrchestrator(session).Publish(context.Background(), request) + if err != nil { + t.Fatalf("Publish() error = %v", err) + } + want := []string{`topic "财经新闻" skipped after 3 attempts`} + if !reflect.DeepEqual(result.Warnings, want) { + t.Fatalf("Warnings = %#v, want %#v", result.Warnings, want) + } +} + func TestOrchestratorPreservesBrowserContextOnStandardFailure(t *testing.T) { session := &fakeBrowserSession{page: &fakePublishPage{uploadErr: errors.New("upload broken")}} request := PublishRequest{Account: "writer", Title: "标题", Content: "正文", Mode: PublishModeOnlySelf, MediaKind: MediaKindStandard, ImagePaths: []string{"cover.jpg"}} diff --git a/internal/xhs/publisher.go b/internal/xhs/publisher.go index cacce75..5a84e9a 100644 --- a/internal/xhs/publisher.go +++ b/internal/xhs/publisher.go @@ -139,6 +139,8 @@ var ( const collectionPopoverTimeout = 2 * time.Second +const topicInputMaxAttempts = 3 + func (p *rodPage) Open(ctx context.Context) error { if err := ctx.Err(); err != nil { return err @@ -190,6 +192,7 @@ func (p *rodPage) FillTitle(ctx context.Context, title string) (err error) { func (p *rodPage) FillContent(ctx context.Context, content string, tags []string) (err error) { done := timing.Stage("xhs.rodPage.FillContent", timing.Field("tags", len(tags))) defer func() { done(err) }() + p.publishWarnings = nil if err := ctx.Err(); err != nil { return err @@ -218,11 +221,35 @@ func (p *rodPage) FillContent(ctx context.Context, content string, tags []string } topicInputDone := timing.Stage("xhs.rodPage.FillContent.input_topics", timing.Field("count", len(topicTags))) var topicErr error + confirmedTopics := 0 for _, tag := range topicTags { - if err := p.inputTopicByKeyboard(field, tag); err != nil { - topicErr = fmt.Errorf("input topic: %w", err) + var lastErr error + for attempt := 1; attempt <= topicInputMaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + topicErr = err + break + } + lastErr = p.inputTopicByKeyboard(field, tag) + if lastErr == nil { + confirmedTopics++ + break + } + if err := p.discardTopicAttempt(field, tag); err != nil { + topicErr = fmt.Errorf("discard failed topic %q after attempt %d: %w", tag, attempt, err) + break + } + } + if topicErr != nil { break } + if lastErr != nil { + warning := fmt.Sprintf("topic %q skipped after %d attempts: %v", tag, topicInputMaxAttempts, lastErr) + p.publishWarnings = append(p.publishWarnings, warning) + defaultXHSLogger("publish warning: %s", warning) + } + } + if topicErr == nil && text == "" && len(topicTags) > 0 && confirmedTopics == 0 { + topicErr = fmt.Errorf("input topic: all %d topics were skipped and publish content is empty", len(topicTags)) } topicInputDone(topicErr) if topicErr != nil { @@ -231,6 +258,80 @@ func (p *rodPage) FillContent(ctx context.Context, content string, tags []string return nil } +func (p *rodPage) PublishWarnings() []string { + if p == nil { + return nil + } + return append([]string(nil), p.publishWarnings...) +} + +func (p *rodPage) discardTopicAttempt(field *rod.Element, tag string) error { + if field == nil { + return fmt.Errorf("editor is nil") + } + if err := rodTry(func() { + field.MustEval(`(tag) => { + const editor = this; + const target = '#' + tag; + const normalize = (value) => (value || '').replace(/\s+/g, ' ').trim(); + let removed = false; + + const suggestions = Array.from(editor.querySelectorAll('.suggestion')); + for (let index = suggestions.length - 1; index >= 0; index--) { + const node = suggestions[index]; + const value = normalize(node.textContent); + if (value === '#' || value === target || target.startsWith(value)) { + node.remove(); + removed = true; + break; + } + } + + if (!removed) { + const nodes = []; + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (parent && parent.closest('a.tiptap-topic[data-topic], .content-hide')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + let value = ''; + while (walker.nextNode()) { + const node = walker.currentNode; + const start = value.length; + value += node.nodeValue || ''; + nodes.push({node, start, end: value.length}); + } + const trimmed = value.replace(/\s+$/u, ''); + const hashIndex = trimmed.lastIndexOf('#'); + const candidate = hashIndex >= 0 ? trimmed.slice(hashIndex) : ''; + if (hashIndex >= 0 && !/[\s#]/u.test(candidate.slice(1)) && (candidate === target || target.startsWith(candidate))) { + const startEntry = nodes.find((entry) => hashIndex >= entry.start && hashIndex <= entry.end); + const endEntry = nodes[nodes.length - 1]; + if (startEntry && endEntry) { + const range = document.createRange(); + range.setStart(startEntry.node, hashIndex - startEntry.start); + range.setEnd(endEntry.node, (endEntry.node.nodeValue || '').length); + range.deleteContents(); + removed = true; + } + } + } + + if (removed) { + editor.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'deleteContentBackward'})); + } + return removed; + }`, tag) + }); err != nil { + return err + } + return focusEditableAtEnd(field) +} + func focusEditableAtEnd(field *rod.Element) error { return rodTry(func() { field.MustEval(`() => { diff --git a/internal/xhs/publisher_test.go b/internal/xhs/publisher_test.go index cfc25f4..9946d9d 100644 --- a/internal/xhs/publisher_test.go +++ b/internal/xhs/publisher_test.go @@ -157,6 +157,7 @@ type fakePublishPage struct { setOnlySelfErr error orderCounter *int firstActionOrder int + publishWarnings []string } func (f *fakePublishPage) Open(context.Context) error { @@ -189,6 +190,10 @@ func (f *fakePublishPage) FillContent(_ context.Context, content string, tags [] return f.contentErr } +func (f *fakePublishPage) PublishWarnings() []string { + return append([]string(nil), f.publishWarnings...) +} + func (f *fakePublishPage) PublishOnlySelf(_ context.Context, request PublishRequest) error { _ = request f.calls = append(f.calls, "publish-only-self") @@ -1668,7 +1673,7 @@ func TestSelectPermissionOptionIgnoresMatchingTextOutsideDropdown(t *testing.T) } } -func TestFillContentRejectsPlainTextTopicWithoutHighlight(t *testing.T) { +func TestFillContentRetriesThenSkipsPlainTextTopicWithoutHighlight(t *testing.T) { page := testPage(t) html := ` @@ -1682,8 +1687,12 @@ func TestFillContentRejectsPlainTextTopicWithoutHighlight(t *testing.T) { const editor = document.querySelector('.tiptap.ProseMirror'); window.spaceConfirmedTopics = []; window.topicTriggerKeySeen = false; + window.topicTriggerCount = 0; editor.addEventListener('keydown', (event) => { - if (event.code === 'Digit3' && event.shiftKey) window.topicTriggerKeySeen = true; + if (event.code === 'Digit3' && event.shiftKey) { + window.topicTriggerKeySeen = true; + window.topicTriggerCount++; + } }); editor.addEventListener('keyup', (event) => { if (event.code !== 'Space' || !window.topicTriggerKeySeen) return; @@ -1699,7 +1708,121 @@ func TestFillContentRejectsPlainTextTopicWithoutHighlight(t *testing.T) { rodPage := &rodPage{page: page, timeouts: rodPageTimeouts{topicSuggestion: 100 * time.Millisecond}} err := rodPage.FillContent(context.Background(), "测试正文", []string{"AI编程"}) - if err == nil || !strings.Contains(err.Error(), "did not enter Xiaohongshu suggestion mode") { + if err != nil { + t.Fatalf("FillContent() error = %v", err) + } + if got := page.MustEval(`() => window.topicTriggerCount`).Int(); got != topicInputMaxAttempts { + t.Fatalf("topic trigger count = %d, want %d", got, topicInputMaxAttempts) + } + warnings := rodPage.PublishWarnings() + if len(warnings) != 1 || !strings.Contains(warnings[0], `topic "AI编程" skipped after 3 attempts`) { + t.Fatalf("warnings = %#v", warnings) + } + text := page.MustEval(`() => document.querySelector('.tiptap.ProseMirror')?.textContent || ''`).String() + if strings.Contains(text, "#AI编程") { + t.Fatalf("editor text = %q, want failed topic removed", text) + } +} + +func TestFillContentContinuesWithNextTopicAfterRetryExhausted(t *testing.T) { + page := testPage(t) + + html := ` + + + +
+ + +` + page.MustNavigate("data:text/html;charset=utf-8," + url.PathEscape(html)) + page.MustWaitLoad() + page.MustElement("body") + + rodPage := &rodPage{page: page, timeouts: rodPageTimeouts{ + topicSuggestion: 50 * time.Millisecond, + topicConfirmation: 50 * time.Millisecond, + topicFallbackSuggestion: 50 * time.Millisecond, + }} + if err := rodPage.FillContent(context.Background(), "测试正文", []string{"坏话题", "好话题"}); err != nil { + t.Fatalf("FillContent() error = %v", err) + } + warnings := rodPage.PublishWarnings() + if len(warnings) != 1 || !strings.Contains(warnings[0], `topic "坏话题" skipped after 3 attempts`) { + t.Fatalf("warnings = %#v", warnings) + } + htmlOut := page.MustEval(`() => document.querySelector('.tiptap.ProseMirror')?.innerHTML || ''`).String() + if strings.Contains(htmlOut, "坏话题") { + t.Fatalf("editor html = %q, want failed topic removed", htmlOut) + } + if !strings.Contains(htmlOut, `class="tiptap-topic"`) || !strings.Contains(htmlOut, "好话题") { + t.Fatalf("editor html = %q, want next topic confirmed", htmlOut) + } +} + +func TestDiscardTopicAttemptKeepsConfirmedTopics(t *testing.T) { + page := testPage(t) + + html := ` + + + +
+ 正文#AI科技[话题]# #财经新闻 +
+ +` + page.MustNavigate("data:text/html;charset=utf-8," + url.PathEscape(html)) + page.MustWaitLoad() + field := page.MustElement(`div.tiptap.ProseMirror`) + + rodPage := &rodPage{page: page} + if err := rodPage.discardTopicAttempt(field, "财经新闻"); err != nil { + t.Fatalf("discardTopicAttempt() error = %v", err) + } + htmlOut := field.MustHTML() + if !strings.Contains(htmlOut, `data-topic=`) || !strings.Contains(htmlOut, "#AI科技") { + t.Fatalf("editor html = %q, want confirmed topic preserved", htmlOut) + } + if strings.Contains(htmlOut, "财经新闻") || strings.Contains(htmlOut, "suggestion") { + t.Fatalf("editor html = %q, want failed topic removed", htmlOut) + } +} + +func TestFillContentFailsWhenAllTopicsAreSkippedAndContentIsEmpty(t *testing.T) { + page := testPage(t) + html := `
` + page.MustNavigate("data:text/html;charset=utf-8," + url.PathEscape(html)) + page.MustWaitLoad() + + rodPage := &rodPage{page: page, timeouts: rodPageTimeouts{topicSuggestion: 20 * time.Millisecond}} + err := rodPage.FillContent(context.Background(), "", []string{"AI编程"}) + if err == nil || !strings.Contains(err.Error(), "all 1 topics were skipped and publish content is empty") { t.Fatalf("FillContent() error = %v", err) } }