feat: enhance feed detail functionality with MCP interface improvements (#45)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zy
2025-09-09 00:55:24 +08:00
committed by GitHub
parent 12ba28759f
commit 27575689a6
8 changed files with 273 additions and 4 deletions

View File

@@ -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{

View File

@@ -163,3 +163,63 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]inter
}},
}
}
// 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),
}},
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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"`
}