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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
65
xiaohongshu/search.go
Normal 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())
|
||||
}
|
||||
34
xiaohongshu/search_test.go
Normal file
34
xiaohongshu/search_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 表示封面信息
|
||||
|
||||
Reference in New Issue
Block a user