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
3 changes: 3 additions & 0 deletions cmd/mark2note/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions cmd/mark2note/main_publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }()
Expand Down
5 changes: 3 additions & 2 deletions internal/xhs/browser_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/xhs/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions internal/xhs/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
105 changes: 103 additions & 2 deletions internal/xhs/publisher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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(`() => {
Expand Down
Loading