feat: 添加搜索功能 (#16)

- 新增 SearchFeeds 服务方法,支持关键词搜索小红书内容
- 添加 search_feeds MCP 工具,提供搜索接口
- 新增 /api/v1/feeds/search API 端点
- 实现搜索页面的浏览器自动化操作
- 优化 MCP 协议支持,处理 notifications/initialized 和 notifications/cancelled 通知
- 更新文档,添加搜索功能说明和使用示例
- 重构类型定义,优化数据结构

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zy
2025-08-17 23:35:22 +08:00
committed by GitHub
parent a0a063d418
commit aa09687751
11 changed files with 241 additions and 18 deletions

View File

@@ -13,8 +13,8 @@ type FeedsListAction struct {
page *rod.Page
}
// InitialState 定义页面初始状态结构
type InitialState struct {
// FeedsResult 定义页面初始状态结构
type FeedsResult struct {
Feed FeedData `json:"feed"`
}
@@ -22,7 +22,7 @@ func NewFeedsListAction(page *rod.Page) *FeedsListAction {
pp := page.Timeout(60 * time.Second)
pp.MustNavigate("https://www.xiaohongshu.com")
pp.MustWaitLoad()
pp.MustWaitStable()
pp.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
return &FeedsListAction{page: pp}
@@ -45,7 +45,7 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) {
}
// 解析完整的 InitialState
var state InitialState
var state FeedsResult
if err := json.Unmarshal([]byte(result), &state); err != nil {
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
}

View File

@@ -35,7 +35,6 @@ func TestGetFeedsList(t *testing.T) {
require.NotEmpty(t, feed.ID, "Feed ID should not be empty")
require.NotEmpty(t, feed.ModelType, "ModelType should not be empty")
require.NotEmpty(t, feed.XsecToken, "XsecToken should not be empty")
require.NotEmpty(t, feed.TrackID, "TrackID should not be empty")
require.NotEmpty(t, feed.NoteCard.Type, "NoteCard Type should not be empty")
require.NotEmpty(t, feed.NoteCard.DisplayTitle, "DisplayTitle should not be empty")
require.NotEmpty(t, feed.NoteCard.User.UserID, "User ID should not be empty")

65
xiaohongshu/search.go Normal file
View File

@@ -0,0 +1,65 @@
package xiaohongshu
import (
"context"
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/go-rod/rod"
)
type SearchResult struct {
Search struct {
Feeds FeedsValue `json:"feeds"`
} `json:"search"`
}
type SearchAction struct {
page *rod.Page
}
func NewSearchAction(page *rod.Page) *SearchAction {
pp := page.Timeout(60 * time.Second)
return &SearchAction{page: pp}
}
func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, error) {
page := s.page.Context(ctx)
searchURL := makeSearchURL(keyword)
page.MustNavigate(searchURL)
page.MustWaitStable()
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
result := page.MustEval(`() => {
if (window.__INITIAL_STATE__) {
return JSON.stringify(window.__INITIAL_STATE__);
}
return "";
}`).String()
if result == "" {
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
}
var searchResult SearchResult
if err := json.Unmarshal([]byte(result), &searchResult); err != nil {
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
}
return searchResult.Search.Feeds.Value, nil
}
func makeSearchURL(keyword string) string {
values := url.Values{}
values.Set("keyword", keyword)
values.Set("source", "web_explore_feed")
return fmt.Sprintf("https://www.xiaohongshu.com/search_result?%s", values.Encode())
}

View File

@@ -0,0 +1,34 @@
package xiaohongshu
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/xpzouying/xiaohongshu-mcp/browser"
)
func TestSearch(t *testing.T) {
t.Skip("SKIP: 测试发布")
b := browser.NewBrowser(false)
defer b.Close()
page := b.NewPage()
defer page.Close()
action := NewSearchAction(page)
feeds, err := action.Search(context.Background(), "Kimi")
require.NoError(t, err)
require.NotEmpty(t, feeds, "feeds should not be empty")
fmt.Printf("成功获取到 %d 个 Feed\n", len(feeds))
for _, feed := range feeds {
fmt.Printf("Feed ID: %s\n", feed.ID)
fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle)
}
}

View File

@@ -19,15 +19,11 @@ type FeedsValue struct {
// Feed 表示单个 Feed 项目
type Feed struct {
XsecToken string `json:"xsecToken"`
ID string `json:"id"`
ModelType string `json:"modelType"`
NoteCard NoteCard `json:"noteCard"`
TrackID string `json:"trackId"`
Ignore bool `json:"ignore"`
Index int `json:"index"`
Exposed bool `json:"exposed"`
SSRRendered bool `json:"ssrRendered"`
XsecToken string `json:"xsecToken"`
ID string `json:"id"`
ModelType string `json:"modelType"`
NoteCard NoteCard `json:"noteCard"`
Index int `json:"index"`
}
// NoteCard 表示笔记卡片信息
@@ -53,6 +49,12 @@ type User struct {
type InteractInfo struct {
Liked bool `json:"liked"`
LikedCount string `json:"likedCount"`
SharedCount string `json:"sharedCount"`
CommentCount string `json:"commentCount"`
CollectedCount string `json:"collectedCount"`
Collected bool `json:"collected"`
}
// Cover 表示封面信息