From 27575689a68e7cb45aa4a6f03e0522688099c11c Mon Sep 17 00:00:00 2001 From: zy Date: Tue, 9 Sep 2025 00:55:24 +0800 Subject: [PATCH] feat: enhance feed detail functionality with MCP interface improvements (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add feed detail page functionality with gin and MCP interfaces Add comprehensive Feed detail page support: - Create new FeedDetailAction in xiaohongshu/feed_detail.go - Add HTTP API endpoint POST /api/v1/feeds/detail - Add MCP tool 'get_feed_detail' for MCP protocol support - Support feed_id and xsec_token parameters (both required) - Raw __INITIAL_STATE__ JSON data saved to feed_detail.json - Return structured data for both HTTP and MCP interfaces 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: enhance feed detail functionality with MCP interface improvements - Add comprehensive feed detail page support with proper data extraction - Create dedicated feed_detail.go file for FeedDetailAction - Optimize Go struct definitions based on actual JSON data analysis - Remove unnecessary fields from FeedDetail, DetailImageInfo, CommentList, and Comment structs - Update MCP interface description to reflect comment retrieval capability - Support both HTTP REST API and MCP protocol interfaces - Implement proper Vue 3 reactive data extraction from window.__INITIAL_STATE__ - Include feed content, user info, interaction data, and comment lists 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: restore JSON file writing for testing and improve code structure - Restore feed_detail.json file writing for testing purposes - Improve error handling by separating marshal and unmarshal steps - Keep the original data extraction logic for complex Vue reactive data structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: simplify JSON unmarshaling using struct instead of map[string]any - Replace complex map[string]any extraction with direct struct unmarshaling - Define inline struct matching the actual JSON response structure - Remove unnecessary extractFeedDetailData and extractNestedValue methods - Significantly reduce code complexity and improve readability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * docs: improve MCP interface descriptions for better usability - Enhance get_feed_detail parameter descriptions with clear source information - Clarify publish_content images parameter supports both local paths and URLs - Improve search_feeds description to specify supported search types - Keep descriptions concise and practical without over-complication 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: revert search_feeds keyword description to keep it simple - Remove unnecessary details from keyword description - Keep interface descriptions concise and clear 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- handlers_api.go | 21 +++++++++++ mcp_handlers.go | 62 +++++++++++++++++++++++++++++- routes.go | 1 + service.go | 25 +++++++++++++ streamable_http.go | 26 +++++++++++-- types.go | 12 ++++++ xiaohongshu/feed_detail.go | 77 ++++++++++++++++++++++++++++++++++++++ xiaohongshu/types.go | 53 ++++++++++++++++++++++++++ 8 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 xiaohongshu/feed_detail.go diff --git a/handlers_api.go b/handlers_api.go index f660d06..32a1f4f 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -103,6 +103,27 @@ func (s *AppServer) searchFeedsHandler(c *gin.Context) { respondSuccess(c, result, "搜索Feeds成功") } +// getFeedDetailHandler 获取Feed详情 +func (s *AppServer) getFeedDetailHandler(c *gin.Context) { + var req FeedDetailRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_REQUEST", + "请求参数错误", err.Error()) + return + } + + // 获取 Feed 详情 + result, err := s.xiaohongshuService.GetFeedDetail(c.Request.Context(), req.FeedID, req.XsecToken) + if err != nil { + respondError(c, http.StatusInternalServerError, "GET_FEED_DETAIL_FAILED", + "获取Feed详情失败", err.Error()) + return + } + + c.Set("account", "ai-report") + respondSuccess(c, result, "获取Feed详情成功") +} + // healthHandler 健康检查 func healthHandler(c *gin.Context) { respondSuccess(c, map[string]any{ diff --git a/mcp_handlers.go b/mcp_handlers.go index 5a857a6..ca1f38e 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -162,4 +162,64 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]inter Text: string(jsonData), }}, } -} \ No newline at end of file +} + +// handleGetFeedDetail 处理获取Feed详情 +func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any) *MCPToolResult { + logrus.Info("MCP: 获取Feed详情") + + // 解析参数 + feedID, ok := args["feed_id"].(string) + if !ok || feedID == "" { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "获取Feed详情失败: 缺少feed_id参数", + }}, + IsError: true, + } + } + + xsecToken, ok := args["xsec_token"].(string) + if !ok || xsecToken == "" { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "获取Feed详情失败: 缺少xsec_token参数", + }}, + IsError: true, + } + } + + logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s", feedID) + + result, err := s.xiaohongshuService.GetFeedDetail(ctx, feedID, xsecToken) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "获取Feed详情失败: " + err.Error(), + }}, + IsError: true, + } + } + + // 格式化输出,转换为JSON字符串 + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: fmt.Sprintf("获取Feed详情成功,但序列化失败: %v", err), + }}, + IsError: true, + } + } + + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(jsonData), + }}, + } +} diff --git a/routes.go b/routes.go index 0cb8670..75b85e2 100644 --- a/routes.go +++ b/routes.go @@ -32,6 +32,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.POST("/publish", appServer.publishHandler) api.GET("/feeds/list", appServer.listFeedsHandler) api.GET("/feeds/search", appServer.searchFeedsHandler) + api.POST("/feeds/detail", appServer.getFeedDetailHandler) } return router diff --git a/service.go b/service.go index 064d757..eeb49cd 100644 --- a/service.go +++ b/service.go @@ -167,3 +167,28 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (* return response, nil } + +// GetFeedDetail 获取Feed详情 +func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { + b := browser.NewBrowser(configs.IsHeadless()) + defer b.Close() + + page := b.NewPage() + defer page.Close() + + // 创建 Feed 详情 action + action := xiaohongshu.NewFeedDetailAction(page) + + // 获取 Feed 详情 + result, err := action.GetFeedDetail(ctx, feedID, xsecToken) + if err != nil { + return nil, err + } + + response := &FeedDetailResponse{ + FeedID: feedID, + Data: result, + } + + return response, nil +} diff --git a/streamable_http.go b/streamable_http.go index 6fbd6d5..6dbefce 100644 --- a/streamable_http.go +++ b/streamable_http.go @@ -176,11 +176,11 @@ func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse { }, "content": map[string]interface{}{ "type": "string", - "description": "正文内容", + "description": "正文内容,支持话题标签", }, "images": map[string]interface{}{ "type": "array", - "description": "图片路径列表(发布图文时使用)", + "description": "图片路径列表,支持本地路径或URL", "items": map[string]interface{}{ "type": "string", }, @@ -203,7 +203,7 @@ func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse { }, { "name": "search_feeds", - "description": "搜索小红书内容(前提:用户已登录)", + "description": "搜索小红书内容(需要已登录)", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -215,6 +215,24 @@ func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse { "required": []string{"keyword"}, }, }, + { + "name": "get_feed_detail", + "description": "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "feed_id": map[string]interface{}{ + "type": "string", + "description": "小红书笔记ID,从Feed列表获取", + }, + "xsec_token": map[string]interface{}{ + "type": "string", + "description": "访问令牌,从Feed列表的xsecToken字段获取", + }, + }, + "required": []string{"feed_id", "xsec_token"}, + }, + }, } return &JSONRPCResponse{ @@ -255,6 +273,8 @@ func (s *AppServer) processToolCall(ctx context.Context, request *JSONRPCRequest result = s.handleListFeeds(ctx) case "search_feeds": result = s.handleSearchFeeds(ctx, toolArgs) + case "get_feed_detail": + result = s.handleGetFeedDetail(ctx, toolArgs) default: return &JSONRPCResponse{ JSONRPC: "2.0", diff --git a/types.go b/types.go index 6f3b3ec..381ba8c 100644 --- a/types.go +++ b/types.go @@ -60,3 +60,15 @@ type MCPContent struct { Type string `json:"type"` Text string `json:"text"` } + +// FeedDetailRequest Feed详情请求 +type FeedDetailRequest struct { + FeedID string `json:"feed_id" binding:"required"` + XsecToken string `json:"xsec_token" binding:"required"` +} + +// FeedDetailResponse Feed详情响应 +type FeedDetailResponse struct { + FeedID string `json:"feed_id"` + Data any `json:"data"` +} diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go new file mode 100644 index 0000000..899c47a --- /dev/null +++ b/xiaohongshu/feed_detail.go @@ -0,0 +1,77 @@ +package xiaohongshu + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/go-rod/rod" +) + +// FeedDetailAction 表示 Feed 详情页动作 +type FeedDetailAction struct { + page *rod.Page +} + +// NewFeedDetailAction 创建 Feed 详情页动作 +func NewFeedDetailAction(page *rod.Page) *FeedDetailAction { + return &FeedDetailAction{page: page} +} + +// GetFeedDetail 获取 Feed 详情页数据 +func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { + page := f.page.Context(ctx).Timeout(60 * time.Second) + + // 构建详情页 URL + url := fmt.Sprintf("https://www.xiaohongshu.com/explore/%s?xsec_token=%s&xsec_source=pc_feed", feedID, xsecToken) + + // 导航到详情页 + page.MustNavigate(url) + 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") + } + + // 将原始结果保存到 feed_detail.json 文件用于测试 + err := os.WriteFile("feed_detail.json", []byte(result), 0644) + if err != nil { + return nil, fmt.Errorf("failed to write feed_detail.json: %w", err) + } + + // 定义响应结构并直接反序列化 + var initialState struct { + Note struct { + NoteDetailMap map[string]struct { + Note FeedDetail `json:"note"` + Comments CommentList `json:"comments"` + } `json:"noteDetailMap"` + } `json:"note"` + } + + if err := json.Unmarshal([]byte(result), &initialState); err != nil { + return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + } + + // 从 noteDetailMap 中获取对应 feedID 的数据 + noteDetail, exists := initialState.Note.NoteDetailMap[feedID] + if !exists { + return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID) + } + + return &FeedDetailResponse{ + Note: noteDetail.Note, + Comments: noteDetail.Comments, + }, nil +} diff --git a/xiaohongshu/types.go b/xiaohongshu/types.go index 01c30bb..327328f 100644 --- a/xiaohongshu/types.go +++ b/xiaohongshu/types.go @@ -83,3 +83,56 @@ type Video struct { type VideoCapability struct { Duration int `json:"duration"` // 视频时长,单位秒 } + +// ================ Feed 详情页相关结构体 ================ + +// FeedDetailResponse 表示 Feed 详情页完整响应 +type FeedDetailResponse struct { + Note FeedDetail `json:"note"` + Comments CommentList `json:"comments"` +} + +// FeedDetail 表示详情页的笔记内容 +type FeedDetail struct { + NoteID string `json:"noteId"` + XsecToken string `json:"xsecToken"` + Title string `json:"title"` + Desc string `json:"desc"` + Type string `json:"type"` + Time int64 `json:"time"` + IPLocation string `json:"ipLocation"` + User User `json:"user"` + InteractInfo InteractInfo `json:"interactInfo"` + ImageList []DetailImageInfo `json:"imageList"` +} + +// DetailImageInfo 表示详情页的图片信息 +type DetailImageInfo struct { + Width int `json:"width"` + Height int `json:"height"` + URLDefault string `json:"urlDefault"` + URLPre string `json:"urlPre"` + LivePhoto bool `json:"livePhoto,omitempty"` +} + +// CommentList 表示评论列表 +type CommentList struct { + List []Comment `json:"list"` + Cursor string `json:"cursor"` + HasMore bool `json:"hasMore"` +} + +// Comment 表示单条评论 +type Comment struct { + ID string `json:"id"` + NoteID string `json:"noteId"` + Content string `json:"content"` + LikeCount string `json:"likeCount"` + CreateTime int64 `json:"createTime"` + IPLocation string `json:"ipLocation"` + Liked bool `json:"liked"` + UserInfo User `json:"userInfo"` + SubCommentCount string `json:"subCommentCount"` + SubComments []Comment `json:"subComments"` + ShowTags []string `json:"showTags"` +}