Skip to content

Feat/add save draft#602

Draft
xiangjiali123 wants to merge 5 commits intoxpzouying:mainfrom
xiangjiali123:feat/add-save-draft
Draft

Feat/add save draft#602
xiangjiali123 wants to merge 5 commits intoxpzouying:mainfrom
xiangjiali123:feat/add-save-draft

Conversation

@xiangjiali123
Copy link
Copy Markdown

Summary

新增小红书草稿能力,提供 3 个 MCP 工具:

  • save_draft:保存草稿(cloud/local 两种模式)
  • list_local_drafts:读取本地草稿列表
  • get_local_draft_detail:按 draft_id 读取本地草稿详情

动机

补齐草稿完整工作流(保存 -> 列表 -> 详情),支持通过 MCP 对本地草稿进行管理与读取。

改动

  • MCP 层新增 3 个草稿工具及参数定义
  • 新增 IndexedDB 草稿读取实现(列表 + 详情)
  • 新增本地暂存服务与发布流程联动
  • 增加浏览器 profile 持久化支持,保证本地草稿可持续读取
  • 更新 README 的草稿工具说明

测试

  • 编译与容器启动通过
  • MCP 工具注册数从 13 增至 16
  • save_draft(local) 可执行
  • list_local_draftsget_local_draft_detail 可调用并返回结果

fixes #348

- 添加save_draft工具
- 由于小红书的暂存功能是保存在浏览器本地而不在云端,save_draft实际上是发布“仅个人可见”笔记
- 私密发布(通过“仅个人可见”发布,保存在云端)
- 暂存(通过调用页面“暂时离开”,存在浏览器本地)
@kimi-agent-bot
Copy link
Copy Markdown
Contributor

🔍 Kimi Code Agent 正在自动审查此 PR...

Copy link
Copy Markdown
Contributor

@kimi-agent-bot kimi-agent-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #602 代码审查

总体评价

本 PR 为项目新增了草稿管理功能(save_draft / list_local_drafts / get_local_draft_detail),补齐了草稿工作流。功能设计上 cloud/local 两种模式的划分合理,IndexedDB 读写和轮询等待机制的实现也比较完整。

但在代码质量方面发现了一些需要修复的问题,主要集中在:缩进破坏、资源泄漏风险、重复代码、以及可能导致 panic 的调用方式


🔴 严重问题(需修复)

1. publish_video.gosubmitPublishVideo 原有代码缩进被破坏

diff 显示原有的 submitPublishVideo 函数末尾被多加了一层缩进(tab),这属于非预期的格式改动,会影响代码可读性和后续 blame 追溯:

-	// 等待发布按钮可点击
-	btn, err := waitForPublishButtonClickable(page)
+		// 等待发布按钮可点击
+		btn, err := waitForPublishButtonClickable(page)
+		if err != nil {
+			return err
+		}
+
+		// 点击发布
+		if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
+			return errors.Wrap(err, "点击发布按钮失败")
 	}

建议:还原 submitPublishVideo 函数中已有代码的缩进,只增加新代码。请运行 gofmtgoimports 确认格式正确。

2. publish.gosubmitLocalDraft 闭合括号缩进异常

	if err := waitAndClickTitleInput(titleElem); err != nil {
		return err
			}  // <-- 此处多了一个 tab

这里的 } 应与 if 对齐。虽然 Go 编译器能通过,但不符合 gofmt 规范,说明该文件可能没有经过 gofmt 格式化。

3. drafts.go — 使用 MustNavigate / MustWaitLoad 会导致 panic

page.MustNavigate("https://creator.xiaohongshu.com/").MustWaitLoad()

Must* 系列方法在失败时会 panic,不适合在生产代码中使用。如果网络超时或页面加载失败,整个进程会崩溃。建议改为:

err := page.Navigate("https://creator.xiaohongshu.com/")
if err != nil {
    return nil, errors.Wrap(err, "导航到创作中心失败")
}
if err := page.WaitLoad(); err != nil {
    return nil, errors.Wrap(err, "等待页面加载失败")
}

ListLocalDraftsGetLocalDraftDetail 中各有一处,需要都修改。

4. service.godraftBrowser 缺少生命周期管理

var (
	draftBrowserOnce sync.Once
	draftBrowser     *headless_browser.Browser
	draftBrowserMu   sync.Mutex
)

func getDraftBrowser() *headless_browser.Browser {
	draftBrowserOnce.Do(func() {
		draftBrowser = newBrowser()
	})
	return draftBrowser
}

draftBrowser 通过 sync.Once 创建后 永远不会被 Close,这意味着:

  • Chrome 子进程在程序整个生命周期中持续占用内存
  • 程序退出时可能残留僵尸进程
  • 与项目中其他浏览器使用模式(defer b.Close())不一致

建议增加一个 closeDraftBrowser() 函数,在程序 shutdown 时调用,或者注册到 signal 处理中:

func closeDraftBrowser() {
	draftBrowserMu.Lock()
	defer draftBrowserMu.Unlock()
	if draftBrowser != nil {
		draftBrowser.Close()
		draftBrowser = nil
	}
}

🟡 中等问题(建议修复)

5. draftBrowserMu 互斥锁粒度过大

当前所有草稿操作(save/list/get)都在函数开始时获取 draftBrowserMu,直到函数结束才释放。这意味着:

  • SaveLocalImageDraft 内部有最多 60 秒的轮询等待(waitForLocalDraftWritten
  • 在此期间 ListLocalDraftsGetLocalDraftDetail 将被阻塞
  • 多个并发的草稿查询也会被串行化

建议考虑:

  • 将锁的范围缩小到仅保护 getDraftBrowser()NewPage() 的调用
  • 或者使用 sync.RWMutex,读操作(list/get)使用读锁

6. submitLocalDraftsubmitLocalVideoDraft 与已有代码高度重复

submitLocalDraftsubmitPublish 大量逻辑重复(标题输入、正文输入、标签处理、设置可见范围等),仅最后一步不同(点击发布 vs 点击暂存离开)。submitLocalVideoDraftsubmitPublishVideo 同理。

建议提取公共的"填充内容"逻辑为一个内部函数,减少维护负担:

func fillPublishForm(page *rod.Page, title, content string, tags []string, ...) error {
    // 标题、正文、标签、定时发布、可见范围、商品等通用逻辑
}

然后 submitPublishsubmitLocalDraftsubmitPublishVideosubmitLocalVideoDraft 调用 fillPublishForm 后执行各自的最终操作。

7. handleGetLocalDraftDetail 中重复定义了两个几乎相同的结构体

type localDraftDetailResp struct {
    Found   bool            `json:"found"`
    Store   string          `json:"store"`
    DraftID string          `json:"draft_id"`
    Type    string          `json:"type"`
    Value   json.RawMessage `json:"value"`
}

// ... 几行后 ...

type outResp struct {
    Found   bool            `json:"found"`
    Store   string          `json:"store"`
    DraftID string          `json:"draft_id"`
    Type    string          `json:"type"`
    Value   json.RawMessage `json:"value,omitempty"`
}

两个结构体唯一区别是 Value 字段的 omitempty tag,实际上可以只保留一个(使用带 omitempty 的版本即可)。

8. browser.go — 猜测性设置多个环境变量

_ = os.Setenv("ROD_BROWSER_USER_DATA_DIR", userDataDir)
_ = os.Setenv("ROD_USER_DATA_DIR", userDataDir)
_ = os.Setenv("CHROME_USER_DATA_DIR", userDataDir)

注释说"兼容多种可能的环境变量命名(以 headless_browser 实现为准)",这意味着开发者并不确定 headless_browser 库实际读取哪个变量。这种"猜测式兼容"有风险:

  • 如果库不读任何一个,该功能完全无效
  • 全局 os.Setenv 会影响所有浏览器实例(不仅仅是 draftBrowser)
  • 未来库升级可能导致静默失效

建议:确认 headless_browser 库的具体实现,或在 headless_browser.Option 中直接传递 user-data-dir(如支持的话)。

9. SaveDraftArgs.Mode — jsonschema 标注与实际行为矛盾

Mode string `json:"mode" jsonschema:"【必填】暂存模式:cloud(...);local(...)"`

jsonschema 描述标注为"必填",但代码中有默认值处理:

if args.Mode == "" {
    args.Mode = "cloud"
}

二者矛盾。建议统一:要么去掉 jsonschema 中的"必填"字样,要么移除默认值并在 Mode 为空时返回错误。


🟢 轻微建议

10. 冗余的 nil 赋值

var scheduleTime *time.Time = nil

Go 中指针类型零值即为 nil= nil 是多余的,直接 var scheduleTime *time.Time 即可。出现在 SaveLocalImageDraftSaveLocalVideoDraft 两处。

11. 大段内嵌 JavaScript

drafts.gopublish.go 中内嵌了数百行 JavaScript 代码(IndexedDB 操作、轮询逻辑等)。建议:

  • 考虑抽取为 const 常量或使用 embed 嵌入独立的 .js 文件
  • 便于单独测试和维护 JS 逻辑

12. 工具计数硬编码

logrus.Infof("Registered %d MCP tools", 16)

每次增减工具都需要手动更新此数字,容易遗忘。建议使用动态计数或直接移除该日志。


总结

类别 数量
🔴 严重 4
🟡 中等 5
🟢 轻微 3

主要需要解决的是:代码格式问题(#1, #2MustNavigate panic 风险(#3、以及 draftBrowser 资源泄漏(#4。修复这些问题后,这个 PR 的质量会好很多。

感谢 @xiangjiali123 的贡献,草稿管理是一个很实用的功能 👍

@kimi-agent-bot
Copy link
Copy Markdown
Contributor

探索仓库 /tmp/kimi-agent-work/xpzouying/xiaohongshu-mcp-pull-request-602 的代码结构。我需要了解:

  1. service.go 中现有的 newBrowser() 函数是怎么定义的
  2. xiaohongshu/publish.go 中现有的 submitPublish 函数的完整实现(特别是缩进风格)
  3. xiaohongshu/publish_video.go 中现有的 submitPublishVideo 函数
  4. mcp_handlers.go 中现有的 import 语句(特别是是否已经导入了 json 和 strings)
  5. 查看是否有 headless_browser 包的接口定义,特别是 Browser 类型是否有 Close 方法

请读取这些文件的关键部分。
已成功对 PR #602 提交了代码审查评论,并请求修改(Changes requested)

审查摘要

🔴 严重问题(4项)

问题 文件 说明
缩进破坏 publish_video.go submitPublishVideo 原有代码被多加了一层缩进
括号缩进异常 publish.go submitLocalDraft 中的 } 多了一个 tab
panic 风险 drafts.go MustNavigate/MustWaitLoad 失败时会 panic
资源泄漏 service.go draftBrowser 仅创建不 Close,无生命周期管理

🟡 中等问题(5项)

问题 建议
锁粒度过大 draftBrowserMu 持有 60 秒会阻塞其他操作
代码重复 submitLocalDraft/submitLocalVideoDraft 与发布逻辑高度重复,建议提取公共函数
结构体重复 localDraftDetailRespoutResp 可合并
环境变量猜测 猜测性设置多个 *_USER_DATA_DIR 不可靠且影响全局
jsonschema 矛盾 Mode 标注"必填"但实际有默认值

🟢 轻微建议(3项)

  • 移除冗余的 = nil 赋值
  • 考虑将大段内嵌 JS 抽取为常量或文件
  • 移除硬编码的工具计数 16

评审结论:功能设计合理,但需要修复上述严重问题后才能合并。建议 @xiangjiali123 优先解决 #1-#4 的代码格式和资源管理问题。

@xiangjiali123 xiangjiali123 marked this pull request as draft March 24, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

期待能新增发布到草稿的选项

1 participant