feat(feeds): Enhance search functionality with additional filter options
- Added support for sorting, note type, time range, search scope, and location distance in the search feeds functionality. - Updated SearchFeedsArgs struct to include new parameters for filtering. - Modified handleSearchFeeds method to process and apply filters during feed search. - Improved logging to include the number of applied filters.
This commit is contained in:
@@ -138,6 +138,24 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "a67793581",
|
||||||
|
"name": "Carlo",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4",
|
||||||
|
"profile": "https://carlo-blog.aiju.fun/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "hrz394943230",
|
||||||
|
"name": "hrz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/28583005?v=4",
|
||||||
|
"profile": "https://github.com/hrz394943230",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,7 +1,7 @@
|
|||||||
# xiaohongshu-mcp
|
# xiaohongshu-mcp
|
||||||
|
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
[](#contributors-)
|
[](#contributors-)
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
MCP for 小红书/xiaohongshu.com。
|
MCP for 小红书/xiaohongshu.com。
|
||||||
@@ -10,6 +10,8 @@ MCP for 小红书/xiaohongshu.com。
|
|||||||
|
|
||||||
**遇到任何问题,务必要先看 [各种疑难杂症](https://github.com/xpzouying/xiaohongshu-mcp/issues/56)**。
|
**遇到任何问题,务必要先看 [各种疑难杂症](https://github.com/xpzouying/xiaohongshu-mcp/issues/56)**。
|
||||||
|
|
||||||
|
上面的 **疑难杂症** 列表后,还是解决不了你的部署问题,那么强烈推荐使用我写的另外一个工具:[xpzouying/x-mcp](https://github.com/xpzouying/x-mcp),这个工具不需要你进行部署,只需要通过浏览器插件就能驱动你的 MCP,对于非技术同学来说更加友好。
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline)
|
[](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline)
|
||||||
@@ -190,6 +192,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
|
|||||||
**小红书基础运营知识**
|
**小红书基础运营知识**
|
||||||
|
|
||||||
- **标题:(非常重要)小红书要求标题不超过 20 个字**
|
- **标题:(非常重要)小红书要求标题不超过 20 个字**
|
||||||
|
- **正文:(非常重要):正文不能超过 1000 个字**
|
||||||
- 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。
|
- 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。
|
||||||
- (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。
|
- (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。
|
||||||
- Tags:现已支持。添加合适的Tags能带来更多的流量。
|
- Tags:现已支持。添加合适的Tags能带来更多的流量。
|
||||||
@@ -736,9 +739,13 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。
|
|||||||
|
|
||||||
<!-- 两列排布:飞书二群 | 微信群 -->
|
<!-- 两列排布:飞书二群 | 微信群 -->
|
||||||
|
|
||||||
| 【飞书二群】:扫码进入 | 【微信群 8 群】:扫码进入 |
|
| 【飞书3群】:扫码进入 | 【微信群 9 群】:扫码进入 |
|
||||||
| ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| <img src="https://github.com/user-attachments/assets/ca1f5d6e-b1bf-4c15-9975-ff75f339ec9b" alt="qrcode_2qun" width="300"> | <img src="https://github.com/user-attachments/assets/69b8169f-a3f7-42bc-b197-86c907b69ea0" alt="WechatIMG119" width="300"> |
|
| <img src="https://github.com/user-attachments/assets/9a0ec41a-cb65-4f4e-a0f7-31658a49512d" alt="qrcode_2qun" width="300"> | <img src="https://github.com/user-attachments/assets/ffddf1f9-3f80-4eb7-9078-5fc357675414" alt="WechatIMG119" width="300"> |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🙏 致谢贡献者 ✨
|
## 🙏 致谢贡献者 ✨
|
||||||
@@ -768,6 +775,10 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmxdawn"><img src="https://avatars.githubusercontent.com/u/21293193?v=4?s=100" width="100px;" alt="lmxdawn"/><br /><sub><b>lmxdawn</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=lmxdawn" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmxdawn"><img src="https://avatars.githubusercontent.com/u/21293193?v=4?s=100" width="100px;" alt="lmxdawn"/><br /><sub><b>lmxdawn</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=lmxdawn" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/haikow"><img src="https://avatars.githubusercontent.com/u/22428382?v=4?s=100" width="100px;" alt="haikow"/><br /><sub><b>haikow</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=haikow" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/haikow"><img src="https://avatars.githubusercontent.com/u/22428382?v=4?s=100" width="100px;" alt="haikow"/><br /><sub><b>haikow</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=haikow" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://carlo-blog.aiju.fun/"><img src="https://avatars.githubusercontent.com/u/18513362?v=4?s=100" width="100px;" alt="Carlo"/><br /><sub><b>Carlo</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=a67793581" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hrz394943230"><img src="https://avatars.githubusercontent.com/u/28583005?v=4?s=100" width="100px;" alt="hrz"/><br /><sub><b>hrz</b></sub></a><br /><a href="https://github.com/xpzouying/xiaohongshu-mcp/commits?author=hrz394943230" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,107 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
祝大家使用 xiaohongshu-mcp 服务愉快哦~
|
祝大家使用 xiaohongshu-mcp 服务愉快哦~
|
||||||
|
|
||||||
|
# xiaohongshu-mcp Windows11快速搭建
|
||||||
|
|
||||||
|
## 1. 下载最新构建版本
|
||||||
|
|
||||||
|
[github.com](https://github.com/xpzouying/xiaohongshu-mcp/releases)
|
||||||
|
|
||||||
|
如果当前系统为Windows 则选择 xiaohongshu-mcp-windows-amd64.zip 下载
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
下载完解压文件
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在当前文件夹中右键打开终端
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
先运行登录命令程序
|
||||||
|
|
||||||
|
```
|
||||||
|
./xiaohongshu-login-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
等待下载完
|
||||||
|
|
||||||
|
## 2. 解决Windows 11 报病毒问题
|
||||||
|
|
||||||
|
在运行之前的程序后会报病毒,如下图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
这时候我们需要打开Windows 安全中心(Windows 11 版本演示)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
点击进入管理设置后,查看最下方的排除项
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
把之前的错误程序的路径添加进去,如下图
|
||||||
|
|
||||||
|
要改成你当前报错的实际路径
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
总结解决路径办法
|
||||||
|
|
||||||
|
解决步骤:
|
||||||
|
|
||||||
|
1. 打开 Windows 安全中心(Windows Security)。
|
||||||
|
|
||||||
|
2. 点击 病毒和威胁防护(Virus & threat protection)。
|
||||||
|
|
||||||
|
3. 在“病毒和威胁防护设置”下,点击 管理设置(Manage settings)。
|
||||||
|
|
||||||
|
4. 向下滚动,找到并点击 添加或删除排除项(Add or remove exclusions)。
|
||||||
|
|
||||||
|
5. 点击 添加排除项(Add an exclusion)。
|
||||||
|
|
||||||
|
6. 选择 文件夹(Folder)。
|
||||||
|
|
||||||
|
7. 导航到以下路径并选择该文件夹:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\Users\你的用户(当前电脑)\AppData\Local\Temp\leakless-amd64-adb80298fa6a3af7ced8b1c9b5f18007
|
||||||
|
```
|
||||||
|
|
||||||
|
8. . 确认添加排除项。
|
||||||
|
|
||||||
|
## 3. 启动程序
|
||||||
|
|
||||||
|
```
|
||||||
|
./xiaohongshu-login-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
登录小红书
|
||||||
|
|
||||||
|
启动MCP服务
|
||||||
|
|
||||||
|
```
|
||||||
|
./xiaohongshu-mcp-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 4. MCP 验证
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
6
errors/errors.go
Normal file
6
errors/errors.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNoFeeds = errors.New("没有捕获到 feeds 数据")
|
||||||
|
var ErrNoFeedDetail = errors.New("没有捕获到 feed 详情数据")
|
||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -117,7 +119,24 @@ func (s *AppServer) listFeedsHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// searchFeedsHandler 搜索Feeds
|
// searchFeedsHandler 搜索Feeds
|
||||||
func (s *AppServer) searchFeedsHandler(c *gin.Context) {
|
func (s *AppServer) searchFeedsHandler(c *gin.Context) {
|
||||||
keyword := c.Query("keyword")
|
var keyword string
|
||||||
|
var filters xiaohongshu.FilterOption
|
||||||
|
|
||||||
|
switch c.Request.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
// 对于POST请求,从JSON中获取keyword
|
||||||
|
var searchReq SearchFeedsRequest
|
||||||
|
if err := c.ShouldBindJSON(&searchReq); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "INVALID_REQUEST",
|
||||||
|
"请求参数错误", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyword = searchReq.Keyword
|
||||||
|
filters = searchReq.Filters
|
||||||
|
default:
|
||||||
|
keyword = c.Query("keyword")
|
||||||
|
}
|
||||||
|
|
||||||
if keyword == "" {
|
if keyword == "" {
|
||||||
respondError(c, http.StatusBadRequest, "MISSING_KEYWORD",
|
respondError(c, http.StatusBadRequest, "MISSING_KEYWORD",
|
||||||
"缺少关键词参数", "keyword parameter is required")
|
"缺少关键词参数", "keyword parameter is required")
|
||||||
@@ -125,7 +144,7 @@ func (s *AppServer) searchFeedsHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索 Feeds
|
// 搜索 Feeds
|
||||||
result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword)
|
result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, http.StatusInternalServerError, "SEARCH_FEEDS_FAILED",
|
respondError(c, http.StatusInternalServerError, "SEARCH_FEEDS_FAILED",
|
||||||
"搜索Feeds失败", err.Error())
|
"搜索Feeds失败", err.Error())
|
||||||
@@ -228,3 +247,17 @@ func healthHandler(c *gin.Context) {
|
|||||||
"timestamp": "now",
|
"timestamp": "now",
|
||||||
}, "服务正常")
|
}, "服务正常")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// myProfileHandler 我的信息
|
||||||
|
func (s *AppServer) myProfileHandler(c *gin.Context) {
|
||||||
|
// 获取当前登录用户信息
|
||||||
|
result, err := s.xiaohongshuService.GetMyProfile(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "GET_MY_PROFILE_FAILED",
|
||||||
|
"获取我的主页失败", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("account", "ai-report")
|
||||||
|
respondSuccess(c, map[string]any{"data": result}, "获取我的主页成功")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -223,12 +224,10 @@ func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSearchFeeds 处理搜索Feeds
|
// handleSearchFeeds 处理搜索Feeds
|
||||||
func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]interface{}) *MCPToolResult {
|
func (s *AppServer) handleSearchFeeds(ctx context.Context, args SearchFeedsArgs) *MCPToolResult {
|
||||||
logrus.Info("MCP: 搜索Feeds")
|
logrus.Info("MCP: 搜索Feeds")
|
||||||
|
|
||||||
// 解析参数
|
if args.Keyword == "" {
|
||||||
keyword, ok := args["keyword"].(string)
|
|
||||||
if !ok || keyword == "" {
|
|
||||||
return &MCPToolResult{
|
return &MCPToolResult{
|
||||||
Content: []MCPContent{{
|
Content: []MCPContent{{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
@@ -238,9 +237,18 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("MCP: 搜索Feeds - 关键词: %s", keyword)
|
logrus.Infof("MCP: 搜索Feeds - 关键词: %s", args.Keyword)
|
||||||
|
|
||||||
result, err := s.xiaohongshuService.SearchFeeds(ctx, keyword)
|
// 将 MCP 的 FilterOption 转换为 xiaohongshu.FilterOption
|
||||||
|
filter := xiaohongshu.FilterOption{
|
||||||
|
SortBy: args.Filters.SortBy,
|
||||||
|
NoteType: args.Filters.NoteType,
|
||||||
|
PublishTime: args.Filters.PublishTime,
|
||||||
|
SearchScope: args.Filters.SearchScope,
|
||||||
|
Location: args.Filters.Location,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.xiaohongshuService.SearchFeeds(ctx, args.Keyword, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &MCPToolResult{
|
return &MCPToolResult{
|
||||||
Content: []MCPContent{{
|
Content: []MCPContent{{
|
||||||
|
|||||||
103
mcp_server.go
103
mcp_server.go
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -28,7 +30,17 @@ type PublishVideoArgs struct {
|
|||||||
|
|
||||||
// SearchFeedsArgs 搜索内容的参数
|
// SearchFeedsArgs 搜索内容的参数
|
||||||
type SearchFeedsArgs struct {
|
type SearchFeedsArgs struct {
|
||||||
Keyword string `json:"keyword" jsonschema:"搜索关键词"`
|
Keyword string `json:"keyword" jsonschema:"搜索关键词"`
|
||||||
|
Filters FilterOption `json:"filters,omitempty" jsonschema:"筛选选项"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterOption 筛选选项结构体
|
||||||
|
type FilterOption struct {
|
||||||
|
SortBy string `json:"sort_by,omitempty" jsonschema:"排序依据: 综合|最新|最多点赞|最多评论|最多收藏,默认为'综合'"`
|
||||||
|
NoteType string `json:"note_type,omitempty" jsonschema:"笔记类型: 不限|视频|图文,默认为'不限'"`
|
||||||
|
PublishTime string `json:"publish_time,omitempty" jsonschema:"发布时间: 不限|一天内|一周内|半年内,默认为'不限'"`
|
||||||
|
SearchScope string `json:"search_scope,omitempty" jsonschema:"搜索范围: 不限|已看过|未看过|已关注,默认为'不限'"`
|
||||||
|
Location string `json:"location,omitempty" jsonschema:"位置距离: 不限|同城|附近,默认为'不限'"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedDetailArgs 获取Feed详情的参数
|
// FeedDetailArgs 获取Feed详情的参数
|
||||||
@@ -68,8 +80,8 @@ type LikeFeedArgs struct {
|
|||||||
|
|
||||||
// FavoriteFeedArgs 收藏参数
|
// FavoriteFeedArgs 收藏参数
|
||||||
type FavoriteFeedArgs struct {
|
type FavoriteFeedArgs struct {
|
||||||
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
||||||
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
||||||
Unfavorite bool `json:"unfavorite,omitempty" jsonschema:"是否取消收藏,true为取消收藏,false或未设置则为收藏"`
|
Unfavorite bool `json:"unfavorite,omitempty" jsonschema:"是否取消收藏,true为取消收藏,false或未设置则为收藏"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +104,38 @@ func InitMCPServer(appServer *AppServer) *mcp.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withPanicRecovery[T any](
|
||||||
|
toolName string,
|
||||||
|
handler func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error),
|
||||||
|
) func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error) {
|
||||||
|
|
||||||
|
return func(ctx context.Context, req *mcp.CallToolRequest, args T) (result *mcp.CallToolResult, resp any, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"tool": toolName,
|
||||||
|
"panic": r,
|
||||||
|
}).Error("Tool handler panicked")
|
||||||
|
|
||||||
|
logrus.Errorf("Stack trace:\n%s", debug.Stack())
|
||||||
|
|
||||||
|
result = &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{
|
||||||
|
&mcp.TextContent{
|
||||||
|
Text: fmt.Sprintf("工具 %s 执行时发生内部错误: %v\n\n请查看服务端日志获取详细信息。", toolName, r),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
resp = nil
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return handler(ctx, req, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// registerTools 注册所有 MCP 工具
|
// registerTools 注册所有 MCP 工具
|
||||||
func registerTools(server *mcp.Server, appServer *AppServer) {
|
func registerTools(server *mcp.Server, appServer *AppServer) {
|
||||||
// 工具 1: 检查登录状态
|
// 工具 1: 检查登录状态
|
||||||
@@ -100,10 +144,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "check_login_status",
|
Name: "check_login_status",
|
||||||
Description: "检查小红书登录状态",
|
Description: "检查小红书登录状态",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("check_login_status", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||||
result := appServer.handleCheckLoginStatus(ctx)
|
result := appServer.handleCheckLoginStatus(ctx)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 2: 获取登录二维码
|
// 工具 2: 获取登录二维码
|
||||||
@@ -112,10 +156,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "get_login_qrcode",
|
Name: "get_login_qrcode",
|
||||||
Description: "获取登录二维码(返回 Base64 图片和超时时间)",
|
Description: "获取登录二维码(返回 Base64 图片和超时时间)",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("get_login_qrcode", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||||
result := appServer.handleGetLoginQrcode(ctx)
|
result := appServer.handleGetLoginQrcode(ctx)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 3: 发布内容
|
// 工具 3: 发布内容
|
||||||
@@ -124,7 +168,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "publish_content",
|
Name: "publish_content",
|
||||||
Description: "发布小红书图文内容",
|
Description: "发布小红书图文内容",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("publish_content", func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) {
|
||||||
// 转换参数格式到现有的 handler
|
// 转换参数格式到现有的 handler
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"title": args.Title,
|
"title": args.Title,
|
||||||
@@ -134,19 +178,19 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
}
|
}
|
||||||
result := appServer.handlePublishContent(ctx, argsMap)
|
result := appServer.handlePublishContent(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 4: 获取Feed列表
|
// 工具 4: 获取Feed列表
|
||||||
mcp.AddTool(server,
|
mcp.AddTool(server,
|
||||||
&mcp.Tool{
|
&mcp.Tool{
|
||||||
Name: "list_feeds",
|
Name: "list_feeds",
|
||||||
Description: "获取用户发布的内容列表",
|
Description: "获取首页 Feeds 列表",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("list_feeds", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||||
result := appServer.handleListFeeds(ctx)
|
result := appServer.handleListFeeds(ctx)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 5: 搜索内容
|
// 工具 5: 搜索内容
|
||||||
@@ -155,13 +199,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "search_feeds",
|
Name: "search_feeds",
|
||||||
Description: "搜索小红书内容(需要已登录)",
|
Description: "搜索小红书内容(需要已登录)",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("search_feeds", func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
result := appServer.handleSearchFeeds(ctx, args)
|
||||||
"keyword": args.Keyword,
|
|
||||||
}
|
|
||||||
result := appServer.handleSearchFeeds(ctx, argsMap)
|
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 6: 获取Feed详情
|
// 工具 6: 获取Feed详情
|
||||||
@@ -170,30 +211,30 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "get_feed_detail",
|
Name: "get_feed_detail",
|
||||||
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表",
|
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
}
|
}
|
||||||
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 7: 获取用户主页
|
// 工具 7: 获取用户主页
|
||||||
mcp.AddTool(server,
|
mcp.AddTool(server,
|
||||||
&mcp.Tool{
|
&mcp.Tool{
|
||||||
Name: "user_profile",
|
Name: "user_profile",
|
||||||
Description: "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
|
Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("user_profile", func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"user_id": args.UserID,
|
"user_id": args.UserID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
}
|
}
|
||||||
result := appServer.handleUserProfile(ctx, argsMap)
|
result := appServer.handleUserProfile(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 8: 发表评论
|
// 工具 8: 发表评论
|
||||||
@@ -202,7 +243,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "post_comment_to_feed",
|
Name: "post_comment_to_feed",
|
||||||
Description: "发表评论到小红书笔记",
|
Description: "发表评论到小红书笔记",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args PostCommentArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("post_comment_to_feed", func(ctx context.Context, req *mcp.CallToolRequest, args PostCommentArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
@@ -210,7 +251,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
}
|
}
|
||||||
result := appServer.handlePostComment(ctx, argsMap)
|
result := appServer.handlePostComment(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 9: 回复评论
|
// 工具 9: 回复评论
|
||||||
@@ -245,7 +286,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "publish_with_video",
|
Name: "publish_with_video",
|
||||||
Description: "发布小红书视频内容(仅支持本地单个视频文件)",
|
Description: "发布小红书视频内容(仅支持本地单个视频文件)",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("publish_with_video", func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"title": args.Title,
|
"title": args.Title,
|
||||||
"content": args.Content,
|
"content": args.Content,
|
||||||
@@ -254,7 +295,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
}
|
}
|
||||||
result := appServer.handlePublishVideo(ctx, argsMap)
|
result := appServer.handlePublishVideo(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 11: 点赞笔记
|
// 工具 11: 点赞笔记
|
||||||
@@ -263,7 +304,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "like_feed",
|
Name: "like_feed",
|
||||||
Description: "为指定笔记点赞或取消点赞(如已点赞将跳过点赞,如未点赞将跳过取消点赞)",
|
Description: "为指定笔记点赞或取消点赞(如已点赞将跳过点赞,如未点赞将跳过取消点赞)",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args LikeFeedArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("like_feed", func(ctx context.Context, req *mcp.CallToolRequest, args LikeFeedArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
@@ -271,7 +312,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
}
|
}
|
||||||
result := appServer.handleLikeFeed(ctx, argsMap)
|
result := appServer.handleLikeFeed(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 工具 12: 收藏笔记
|
// 工具 12: 收藏笔记
|
||||||
@@ -280,7 +321,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
Name: "favorite_feed",
|
Name: "favorite_feed",
|
||||||
Description: "收藏指定笔记或取消收藏(如已收藏将跳过收藏,如未收藏将跳过取消收藏)",
|
Description: "收藏指定笔记或取消收藏(如已收藏将跳过收藏,如未收藏将跳过取消收藏)",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args FavoriteFeedArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("favorite_feed", func(ctx context.Context, req *mcp.CallToolRequest, args FavoriteFeedArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
@@ -288,7 +329,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
}
|
}
|
||||||
result := appServer.handleFavoriteFeed(ctx, argsMap)
|
result := appServer.handleFavoriteFeed(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
logrus.Infof("Registered %d MCP tools", 11)
|
logrus.Infof("Registered %d MCP tools", 11)
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
|
|||||||
api.POST("/publish_video", appServer.publishVideoHandler)
|
api.POST("/publish_video", appServer.publishVideoHandler)
|
||||||
api.GET("/feeds/list", appServer.listFeedsHandler)
|
api.GET("/feeds/list", appServer.listFeedsHandler)
|
||||||
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
||||||
|
api.POST("/feeds/search", appServer.searchFeedsHandler)
|
||||||
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
|
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
|
||||||
api.POST("/user/profile", appServer.userProfileHandler)
|
api.POST("/user/profile", appServer.userProfileHandler)
|
||||||
api.POST("/feeds/comment", appServer.postCommentHandler)
|
api.POST("/feeds/comment", appServer.postCommentHandler)
|
||||||
api.POST("/feeds/comment/reply", appServer.replyCommentHandler)
|
api.POST("/feeds/comment/reply", appServer.replyCommentHandler)
|
||||||
|
api.GET("/user/me", appServer.myProfileHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
46
service.go
46
service.go
@@ -4,6 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -13,8 +16,6 @@ import (
|
|||||||
"github.com/xpzouying/xiaohongshu-mcp/cookies"
|
"github.com/xpzouying/xiaohongshu-mcp/cookies"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
|
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// XiaohongshuService 小红书业务服务
|
// XiaohongshuService 小红书业务服务
|
||||||
@@ -181,6 +182,7 @@ func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishReq
|
|||||||
|
|
||||||
// 执行发布
|
// 执行发布
|
||||||
if err := s.publishContent(ctx, content); err != nil {
|
if err := s.publishContent(ctx, content); err != nil {
|
||||||
|
logrus.Errorf("发布内容失败: title=%s %v", content.Title, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse,
|
|||||||
// 获取 Feeds 列表
|
// 获取 Feeds 列表
|
||||||
feeds, err := action.GetFeedsList(ctx)
|
feeds, err := action.GetFeedsList(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logrus.Errorf("获取 Feeds 列表失败: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +298,7 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse,
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (*FeedsListResponse, error) {
|
func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, filters ...xiaohongshu.FilterOption) (*FeedsListResponse, error) {
|
||||||
b := newBrowser()
|
b := newBrowser()
|
||||||
defer b.Close()
|
defer b.Close()
|
||||||
|
|
||||||
@@ -304,7 +307,7 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (*
|
|||||||
|
|
||||||
action := xiaohongshu.NewSearchAction(page)
|
action := xiaohongshu.NewSearchAction(page)
|
||||||
|
|
||||||
feeds, err := action.Search(ctx, keyword)
|
feeds, err := action.Search(ctx, keyword, filters...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -484,3 +487,38 @@ func saveCookies(page *rod.Page) error {
|
|||||||
cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath())
|
cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath())
|
||||||
return cookieLoader.SaveCookies(data)
|
return cookieLoader.SaveCookies(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withBrowserPage 执行需要浏览器页面的操作的通用函数
|
||||||
|
func withBrowserPage(fn func(*rod.Page) error) error {
|
||||||
|
b := newBrowser()
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
page := b.NewPage()
|
||||||
|
defer page.Close()
|
||||||
|
|
||||||
|
return fn(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyProfile 获取当前登录用户的个人信息
|
||||||
|
func (s *XiaohongshuService) GetMyProfile(ctx context.Context) (*UserProfileResponse, error) {
|
||||||
|
var result *xiaohongshu.UserProfileResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
err = withBrowserPage(func(page *rod.Page) error {
|
||||||
|
action := xiaohongshu.NewUserProfileAction(page)
|
||||||
|
result, err = action.GetMyProfileViaSidebar(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &UserProfileResponse{
|
||||||
|
UserBasicInfo: result.UserBasicInfo,
|
||||||
|
Interactions: result.Interactions,
|
||||||
|
Feeds: result.Feeds,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|||||||
7
types.go
7
types.go
@@ -1,5 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
|
|
||||||
// HTTP API 响应类型
|
// HTTP API 响应类型
|
||||||
|
|
||||||
// ErrorResponse 错误响应
|
// ErrorResponse 错误响应
|
||||||
@@ -38,6 +40,11 @@ type FeedDetailRequest struct {
|
|||||||
XsecToken string `json:"xsec_token" binding:"required"`
|
XsecToken string `json:"xsec_token" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchFeedsRequest struct {
|
||||||
|
Keyword string `json:"keyword" binding:"required"`
|
||||||
|
Filters xiaohongshu.FilterOption `json:"filters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// FeedDetailResponse Feed详情响应
|
// FeedDetailResponse Feed详情响应
|
||||||
type FeedDetailResponse struct {
|
type FeedDetailResponse struct {
|
||||||
FeedID string `json:"feed_id"`
|
FeedID string `json:"feed_id"`
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FeedDetailAction 表示 Feed 详情页动作
|
// FeedDetailAction 表示 Feed 详情页动作
|
||||||
@@ -26,39 +28,37 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
|
|||||||
// 构建详情页 URL
|
// 构建详情页 URL
|
||||||
url := makeFeedDetailURL(feedID, xsecToken)
|
url := makeFeedDetailURL(feedID, xsecToken)
|
||||||
|
|
||||||
|
logrus.Infof("打开 feed 详情页: %s", url)
|
||||||
|
|
||||||
// 导航到详情页
|
// 导航到详情页
|
||||||
page.MustNavigate(url)
|
page.MustNavigate(url)
|
||||||
page.MustWaitDOMStable()
|
page.MustWaitDOMStable()
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.note &&
|
||||||
|
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||||
|
const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap;
|
||||||
|
return JSON.stringify(noteDetailMap);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义响应结构并直接反序列化
|
var noteDetailMap map[string]struct {
|
||||||
var initialState struct {
|
Note FeedDetail `json:"note"`
|
||||||
Note struct {
|
Comments CommentList `json:"comments"`
|
||||||
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 {
|
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal noteDetailMap: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 noteDetailMap 中获取对应 feedID 的数据
|
noteDetail, exists := noteDetailMap[feedID]
|
||||||
noteDetail, exists := initialState.Note.NoteDetailMap[feedID]
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID)
|
return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeedsListAction struct {
|
type FeedsListAction struct {
|
||||||
page *rod.Page
|
page *rod.Page
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedsResult 定义页面初始状态结构
|
|
||||||
type FeedsResult struct {
|
|
||||||
Feed FeedData `json:"feed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFeedsListAction(page *rod.Page) *FeedsListAction {
|
func NewFeedsListAction(page *rod.Page) *FeedsListAction {
|
||||||
pp := page.Timeout(60 * time.Second)
|
pp := page.Timeout(60 * time.Second)
|
||||||
|
|
||||||
@@ -33,24 +29,27 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) {
|
|||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.feed &&
|
||||||
|
window.__INITIAL_STATE__.feed.feeds) {
|
||||||
|
const feeds = window.__INITIAL_STATE__.feed.feeds;
|
||||||
|
const feedsData = feeds.value !== undefined ? feeds.value : feeds._value;
|
||||||
|
if (feedsData) {
|
||||||
|
return JSON.stringify(feedsData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeeds
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析完整的 InitialState
|
var feeds []Feed
|
||||||
var state FeedsResult
|
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 feed.feeds._value
|
return feeds, nil
|
||||||
return state.Feed.Feeds.Value, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
myerrors "github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActionResult 通用动作响应(点赞/收藏等)
|
// ActionResult 通用动作响应(点赞/收藏等)
|
||||||
@@ -213,33 +214,33 @@ func (a *FavoriteAction) toggleFavorite(page *rod.Page, feedID string, targetCol
|
|||||||
|
|
||||||
// getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态
|
// getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态
|
||||||
func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) {
|
func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) {
|
||||||
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.note &&
|
||||||
|
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||||
|
return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return false, false, fmt.Errorf("__INITIAL_STATE__ not found")
|
return false, false, myerrors.ErrNoFeedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
var state struct {
|
// 直接解析为 noteDetailMap
|
||||||
|
var noteDetailMap map[string]struct {
|
||||||
Note struct {
|
Note struct {
|
||||||
NoteDetailMap map[string]struct {
|
InteractInfo struct {
|
||||||
Note struct {
|
Liked bool `json:"liked"`
|
||||||
InteractInfo struct {
|
Collected bool `json:"collected"`
|
||||||
Liked bool `json:"liked"`
|
} `json:"interactInfo"`
|
||||||
Collected bool `json:"collected"`
|
|
||||||
} `json:"interactInfo"`
|
|
||||||
} `json:"note"`
|
|
||||||
} `json:"noteDetailMap"`
|
|
||||||
} `json:"note"`
|
} `json:"note"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||||
return false, false, errors.Wrap(err, "unmarshal __INITIAL_STATE__ failed")
|
return false, false, errors.Wrap(err, "unmarshal noteDetailMap failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, ok := state.Note.NoteDetailMap[feedID]
|
detail, ok := noteDetailMap[feedID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false, fmt.Errorf("feed %s not in noteDetailMap", feedID)
|
return false, false, fmt.Errorf("feed %s not in noteDetailMap", feedID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,23 @@ func (n *NavigateAction) ToExplorePage(ctx context.Context) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NavigateAction) ToProfilePage(ctx context.Context) error {
|
||||||
|
page := n.page.Context(ctx)
|
||||||
|
|
||||||
|
// First navigate to explore page
|
||||||
|
if err := n.ToExplorePage(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
page.MustWaitStable()
|
||||||
|
|
||||||
|
// Find and click the "我" channel link in sidebar
|
||||||
|
profileLink := page.MustElement(`div.main-container li.user.side-bar-component a.link-wrapper span.channel`)
|
||||||
|
profileLink.MustClick()
|
||||||
|
|
||||||
|
// Wait for navigation to complete
|
||||||
|
page.MustWaitLoad()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const (
|
|||||||
|
|
||||||
func NewPublishImageAction(page *rod.Page) (*PublishAction, error) {
|
func NewPublishImageAction(page *rod.Page) (*PublishAction, error) {
|
||||||
|
|
||||||
pp := page.Timeout(180 * time.Second)
|
pp := page.Timeout(300 * time.Second)
|
||||||
|
|
||||||
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
@@ -68,7 +68,15 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent
|
|||||||
return errors.Wrap(err, "小红书上传图片失败")
|
return errors.Wrap(err, "小红书上传图片失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := submitPublish(page, content.Title, content.Content, content.Tags); err != nil {
|
tags := content.Tags
|
||||||
|
if len(tags) >= 10 {
|
||||||
|
logrus.Warnf("标签数量超过10,截取前10个标签")
|
||||||
|
tags = tags[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Infof("发布内容: title=%s, images=%v, tags=%v", content.Title, len(content.ImagePaths), tags)
|
||||||
|
|
||||||
|
if err := submitPublish(page, content.Title, content.Content, tags); err != nil {
|
||||||
return errors.Wrap(err, "小红书发布失败")
|
return errors.Wrap(err, "小红书发布失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,20 +194,25 @@ func uploadImages(page *rod.Page, imagesPaths []string) error {
|
|||||||
pp := page.Timeout(30 * time.Second)
|
pp := page.Timeout(30 * time.Second)
|
||||||
|
|
||||||
// 验证文件路径有效性
|
// 验证文件路径有效性
|
||||||
|
validPaths := make([]string, 0, len(imagesPaths))
|
||||||
for _, path := range imagesPaths {
|
for _, path := range imagesPaths {
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
return errors.Wrapf(err, "图片文件不存在: %s", path)
|
logrus.Warnf("图片文件不存在: %s", path)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
validPaths = append(validPaths, path)
|
||||||
|
|
||||||
|
logrus.Infof("获取有效图片:%s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待上传输入框出现
|
// 等待上传输入框出现
|
||||||
uploadInput := pp.MustElement(".upload-input")
|
uploadInput := pp.MustElement(".upload-input")
|
||||||
|
|
||||||
// 上传多个文件
|
// 上传多个文件
|
||||||
uploadInput.MustSetFiles(imagesPaths...)
|
uploadInput.MustSetFiles(validPaths...)
|
||||||
|
|
||||||
// 等待并验证上传完成
|
// 等待并验证上传完成
|
||||||
return waitForUploadComplete(pp, len(imagesPaths))
|
return waitForUploadComplete(pp, len(validPaths))
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForUploadComplete 等待并验证上传完成
|
// waitForUploadComplete 等待并验证上传完成
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -16,6 +17,144 @@ type SearchResult struct {
|
|||||||
} `json:"search"`
|
} `json:"search"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterOption 筛选选项结构体
|
||||||
|
type FilterOption struct {
|
||||||
|
SortBy string `json:"sort_by,omitempty" jsonschema:"排序依据: 综合|最新|最多点赞|最多评论|最多收藏,默认为'综合'"`
|
||||||
|
NoteType string `json:"note_type,omitempty" jsonschema:"笔记类型: 不限|视频|图文,默认为'不限'"`
|
||||||
|
PublishTime string `json:"publish_time,omitempty" jsonschema:"发布时间: 不限|一天内|一周内|半年内,默认为'不限'"`
|
||||||
|
SearchScope string `json:"search_scope,omitempty" jsonschema:"搜索范围: 不限|已看过|未看过|已关注,默认为'不限'"`
|
||||||
|
Location string `json:"location,omitempty" jsonschema:"位置距离: 不限|同城|附近,默认为'不限'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalFilterOption 内部使用的筛选选项(基于索引)
|
||||||
|
type internalFilterOption struct {
|
||||||
|
FiltersIndex int // 筛选组索引
|
||||||
|
TagsIndex int // 标签索引
|
||||||
|
Text string // 标签文本描述
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预定义的筛选选项映射表(内部使用)
|
||||||
|
var filterOptionsMap = map[int][]internalFilterOption{
|
||||||
|
1: { // 排序依据
|
||||||
|
{FiltersIndex: 1, TagsIndex: 1, Text: "综合"},
|
||||||
|
{FiltersIndex: 1, TagsIndex: 2, Text: "最新"},
|
||||||
|
{FiltersIndex: 1, TagsIndex: 3, Text: "最多点赞"},
|
||||||
|
{FiltersIndex: 1, TagsIndex: 4, Text: "最多评论"},
|
||||||
|
{FiltersIndex: 1, TagsIndex: 5, Text: "最多收藏"},
|
||||||
|
},
|
||||||
|
2: { // 笔记类型
|
||||||
|
{FiltersIndex: 2, TagsIndex: 1, Text: "不限"},
|
||||||
|
{FiltersIndex: 2, TagsIndex: 2, Text: "视频"},
|
||||||
|
{FiltersIndex: 2, TagsIndex: 3, Text: "图文"},
|
||||||
|
},
|
||||||
|
3: { // 发布时间
|
||||||
|
{FiltersIndex: 3, TagsIndex: 1, Text: "不限"},
|
||||||
|
{FiltersIndex: 3, TagsIndex: 2, Text: "一天内"},
|
||||||
|
{FiltersIndex: 3, TagsIndex: 3, Text: "一周内"},
|
||||||
|
{FiltersIndex: 3, TagsIndex: 4, Text: "半年内"},
|
||||||
|
},
|
||||||
|
4: { // 搜索范围
|
||||||
|
{FiltersIndex: 4, TagsIndex: 1, Text: "不限"},
|
||||||
|
{FiltersIndex: 4, TagsIndex: 2, Text: "已看过"},
|
||||||
|
{FiltersIndex: 4, TagsIndex: 3, Text: "未看过"},
|
||||||
|
{FiltersIndex: 4, TagsIndex: 4, Text: "已关注"},
|
||||||
|
},
|
||||||
|
5: { // 位置距离
|
||||||
|
{FiltersIndex: 5, TagsIndex: 1, Text: "不限"},
|
||||||
|
{FiltersIndex: 5, TagsIndex: 2, Text: "同城"},
|
||||||
|
{FiltersIndex: 5, TagsIndex: 3, Text: "附近"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToInternalFilters 将 FilterOption 转换为内部的 internalFilterOption 列表
|
||||||
|
func convertToInternalFilters(filter FilterOption) ([]internalFilterOption, error) {
|
||||||
|
var internalFilters []internalFilterOption
|
||||||
|
|
||||||
|
// 处理排序依据
|
||||||
|
if filter.SortBy != "" {
|
||||||
|
internal, err := findInternalOption(1, filter.SortBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("排序依据错误: %w", err)
|
||||||
|
}
|
||||||
|
internalFilters = append(internalFilters, internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理笔记类型
|
||||||
|
if filter.NoteType != "" {
|
||||||
|
internal, err := findInternalOption(2, filter.NoteType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("笔记类型错误: %w", err)
|
||||||
|
}
|
||||||
|
internalFilters = append(internalFilters, internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理发布时间
|
||||||
|
if filter.PublishTime != "" {
|
||||||
|
internal, err := findInternalOption(3, filter.PublishTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("发布时间错误: %w", err)
|
||||||
|
}
|
||||||
|
internalFilters = append(internalFilters, internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索范围
|
||||||
|
if filter.SearchScope != "" {
|
||||||
|
internal, err := findInternalOption(4, filter.SearchScope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索范围错误: %w", err)
|
||||||
|
}
|
||||||
|
internalFilters = append(internalFilters, internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理位置距离
|
||||||
|
if filter.Location != "" {
|
||||||
|
internal, err := findInternalOption(5, filter.Location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("位置距离错误: %w", err)
|
||||||
|
}
|
||||||
|
internalFilters = append(internalFilters, internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalFilters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findInternalOption 根据筛选组索引和文本查找内部筛选选项
|
||||||
|
func findInternalOption(filtersIndex int, text string) (internalFilterOption, error) {
|
||||||
|
options, exists := filterOptionsMap[filtersIndex]
|
||||||
|
if !exists {
|
||||||
|
return internalFilterOption{}, fmt.Errorf("筛选组 %d 不存在", filtersIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, option := range options {
|
||||||
|
if option.Text == text {
|
||||||
|
return option, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalFilterOption{}, fmt.Errorf("在筛选组 %d 中未找到文本 '%s'", filtersIndex, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateInternalFilterOption 验证内部筛选选项是否在有效范围内
|
||||||
|
func validateInternalFilterOption(filter internalFilterOption) error {
|
||||||
|
// 检查筛选组索引是否有效
|
||||||
|
if filter.FiltersIndex < 1 || filter.FiltersIndex > 5 {
|
||||||
|
return fmt.Errorf("无效的筛选组索引 %d,有效范围为 1-5", filter.FiltersIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查标签索引是否在对应筛选组的有效范围内
|
||||||
|
options, exists := filterOptionsMap[filter.FiltersIndex]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("筛选组 %d 不存在", filter.FiltersIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.TagsIndex < 1 || filter.TagsIndex > len(options) {
|
||||||
|
return fmt.Errorf("筛选组 %d 的标签索引 %d 超出范围,有效范围为 1-%d",
|
||||||
|
filter.FiltersIndex, filter.TagsIndex, len(options))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type SearchAction struct {
|
type SearchAction struct {
|
||||||
page *rod.Page
|
page *rod.Page
|
||||||
}
|
}
|
||||||
@@ -26,7 +165,7 @@ func NewSearchAction(page *rod.Page) *SearchAction {
|
|||||||
return &SearchAction{page: pp}
|
return &SearchAction{page: pp}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, error) {
|
func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...FilterOption) ([]Feed, error) {
|
||||||
page := s.page.Context(ctx)
|
page := s.page.Context(ctx)
|
||||||
|
|
||||||
searchURL := makeSearchURL(keyword)
|
searchURL := makeSearchURL(keyword)
|
||||||
@@ -35,24 +174,69 @@ func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, erro
|
|||||||
|
|
||||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
// 如果有筛选条件,则应用筛选
|
||||||
result := page.MustEval(`() => {
|
if len(filters) > 0 {
|
||||||
if (window.__INITIAL_STATE__) {
|
// 将所有 FilterOption 转换为内部筛选选项
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
var allInternalFilters []internalFilterOption
|
||||||
|
for _, filter := range filters {
|
||||||
|
internalFilters, err := convertToInternalFilters(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("筛选选项转换失败: %w", err)
|
||||||
}
|
}
|
||||||
return "";
|
allInternalFilters = append(allInternalFilters, internalFilters...)
|
||||||
}`).String()
|
}
|
||||||
|
|
||||||
|
// 验证所有内部筛选选项
|
||||||
|
for _, filter := range allInternalFilters {
|
||||||
|
if err := validateInternalFilterOption(filter); err != nil {
|
||||||
|
return nil, fmt.Errorf("筛选选项验证失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 悬停在筛选按钮上
|
||||||
|
filterButton := page.MustElement(`div.filter`)
|
||||||
|
filterButton.MustHover()
|
||||||
|
|
||||||
|
// 等待筛选面板出现
|
||||||
|
page.MustWait(`() => document.querySelector('div.filter-panel') !== null`)
|
||||||
|
|
||||||
|
// 应用所有筛选条件
|
||||||
|
for _, filter := range allInternalFilters {
|
||||||
|
selector := fmt.Sprintf(`div.filter-panel div.filters:nth-child(%d) div.tags:nth-child(%d)`,
|
||||||
|
filter.FiltersIndex, filter.TagsIndex)
|
||||||
|
option := page.MustElement(selector)
|
||||||
|
option.MustClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待页面更新
|
||||||
|
page.MustWaitStable()
|
||||||
|
// 重新等待 __INITIAL_STATE__ 更新
|
||||||
|
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := page.MustEval(`() => {
|
||||||
|
if (window.__INITIAL_STATE__ &&
|
||||||
|
window.__INITIAL_STATE__.search &&
|
||||||
|
window.__INITIAL_STATE__.search.feeds) {
|
||||||
|
const feeds = window.__INITIAL_STATE__.search.feeds;
|
||||||
|
const feedsData = feeds.value !== undefined ? feeds.value : feeds._value;
|
||||||
|
if (feedsData) {
|
||||||
|
return JSON.stringify(feedsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeeds
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResult SearchResult
|
var feeds []Feed
|
||||||
if err := json.Unmarshal([]byte(result), &searchResult); err != nil {
|
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchResult.Search.Feeds.Value, nil
|
return feeds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeSearchURL(keyword string) string {
|
func makeSearchURL(keyword string) string {
|
||||||
@@ -61,5 +245,7 @@ func makeSearchURL(keyword string) string {
|
|||||||
values.Set("keyword", keyword)
|
values.Set("keyword", keyword)
|
||||||
values.Set("source", "web_explore_feed")
|
values.Set("source", "web_explore_feed")
|
||||||
|
|
||||||
|
//https://www.xiaohongshu.com/search_result?keyword=%25E7%258E%258B%25E5%25AD%2590&source=web_search_result_notes
|
||||||
|
//https://www.xiaohongshu.com/search_result?keyword=%25E7%258E%258B%25E5%25AD%2590&source=web_explore_feed
|
||||||
return fmt.Sprintf("https://www.xiaohongshu.com/search_result?%s", values.Encode())
|
return fmt.Sprintf("https://www.xiaohongshu.com/search_result?%s", values.Encode())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ func TestSearch(t *testing.T) {
|
|||||||
defer b.Close()
|
defer b.Close()
|
||||||
|
|
||||||
page := b.NewPage()
|
page := b.NewPage()
|
||||||
defer page.Close()
|
defer func() {
|
||||||
|
_ = page.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
action := NewSearchAction(page)
|
action := NewSearchAction(page)
|
||||||
|
|
||||||
@@ -32,3 +34,72 @@ func TestSearch(t *testing.T) {
|
|||||||
fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle)
|
fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchWithFilters(t *testing.T) {
|
||||||
|
|
||||||
|
//t.Skip("SKIP: 测试筛选功能")
|
||||||
|
|
||||||
|
b := browser.NewBrowser(false)
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
page := b.NewPage()
|
||||||
|
defer func() {
|
||||||
|
_ = page.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
action := NewSearchAction(page)
|
||||||
|
|
||||||
|
// 使用新的 FilterOption 结构
|
||||||
|
filter := FilterOption{
|
||||||
|
NoteType: "图文",
|
||||||
|
PublishTime: "一天内",
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds, err := action.Search(context.Background(), "dn432", filter)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterValidation(t *testing.T) {
|
||||||
|
// 测试有效的筛选选项转换
|
||||||
|
validFilter := FilterOption{
|
||||||
|
NoteType: "图文",
|
||||||
|
PublishTime: "一天内",
|
||||||
|
}
|
||||||
|
internalFilters, err := convertToInternalFilters(validFilter)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, internalFilters, 2)
|
||||||
|
|
||||||
|
// 验证转换后的内部筛选选项
|
||||||
|
for _, filter := range internalFilters {
|
||||||
|
err := validateInternalFilterOption(filter)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试无效的筛选值
|
||||||
|
invalidFilter := FilterOption{
|
||||||
|
NoteType: "不存在的类型",
|
||||||
|
}
|
||||||
|
_, err = convertToInternalFilters(invalidFilter)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "未找到文本")
|
||||||
|
|
||||||
|
// 测试所有有效的筛选选项
|
||||||
|
allFilters := FilterOption{
|
||||||
|
SortBy: "最新",
|
||||||
|
NoteType: "视频",
|
||||||
|
PublishTime: "一周内",
|
||||||
|
SearchScope: "已关注",
|
||||||
|
Location: "同城",
|
||||||
|
}
|
||||||
|
internalFilters, err = convertToInternalFilters(allFilters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, internalFilters, 5)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,46 +26,97 @@ func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken s
|
|||||||
page.MustNavigate(searchURL)
|
page.MustNavigate(searchURL)
|
||||||
page.MustWaitStable()
|
page.MustWaitStable()
|
||||||
|
|
||||||
|
return u.extractUserProfileData(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserProfileData 从页面中提取用户资料数据的通用方法
|
||||||
|
func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) {
|
||||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
userDataResult := page.MustEval(`() => {
|
||||||
result := page.MustEval(`() => {
|
if (window.__INITIAL_STATE__ &&
|
||||||
if (window.__INITIAL_STATE__) {
|
window.__INITIAL_STATE__.user &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.user.userPageData) {
|
||||||
|
const userPageData = window.__INITIAL_STATE__.user.userPageData;
|
||||||
|
const data = userPageData.value !== undefined ? userPageData.value : userPageData._value;
|
||||||
|
if (data) {
|
||||||
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
return "";
|
}
|
||||||
}`).String()
|
return "";
|
||||||
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if userDataResult == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, fmt.Errorf("user.userPageData.value not found in __INITIAL_STATE__")
|
||||||
}
|
}
|
||||||
// 定义响应结构并直接反序列化
|
|
||||||
var initialState = struct {
|
// 2. 获取用户帖子:window.__INITIAL_STATE__.user.notes.value
|
||||||
User struct {
|
notesResult := page.MustEval(`() => {
|
||||||
UserPageData UserPageData `json:"userPageData"`
|
if (window.__INITIAL_STATE__ &&
|
||||||
Notes struct {
|
window.__INITIAL_STATE__.user &&
|
||||||
Feeds [][]Feed `json:"_rawValue"` // 帖子为双重数组
|
window.__INITIAL_STATE__.user.notes) {
|
||||||
} `json:"notes"`
|
const notes = window.__INITIAL_STATE__.user.notes;
|
||||||
} `json:"user"`
|
// 优先使用 value(getter),如果不存在则使用 _value(内部字段)
|
||||||
}{}
|
const data = notes.value !== undefined ? notes.value : notes._value;
|
||||||
if err := json.Unmarshal([]byte(result), &initialState); err != nil {
|
if (data) {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}`).String()
|
||||||
|
|
||||||
|
if notesResult == "" {
|
||||||
|
return nil, fmt.Errorf("user.notes.value not found in __INITIAL_STATE__")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析用户信息
|
||||||
|
var userPageData struct {
|
||||||
|
Interactions []UserInteractions `json:"interactions"`
|
||||||
|
BasicInfo UserBasicInfo `json:"basicInfo"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(userDataResult), &userPageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal userPageData: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析帖子数据(帖子为双重数组)
|
||||||
|
var notesFeeds [][]Feed
|
||||||
|
if err := json.Unmarshal([]byte(notesResult), ¬esFeeds); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal notes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装响应
|
||||||
response := &UserProfileResponse{
|
response := &UserProfileResponse{
|
||||||
UserBasicInfo: initialState.User.UserPageData.RawValue.BasicInfo,
|
UserBasicInfo: userPageData.BasicInfo,
|
||||||
Interactions: initialState.User.UserPageData.RawValue.Interactions,
|
Interactions: userPageData.Interactions,
|
||||||
}
|
}
|
||||||
// 添加用户贴子
|
|
||||||
for _, feeds := range initialState.User.Notes.Feeds {
|
// 添加用户帖子(展平双重数组)
|
||||||
|
for _, feeds := range notesFeeds {
|
||||||
if len(feeds) != 0 {
|
if len(feeds) != 0 {
|
||||||
response.Feeds = append(response.Feeds, feeds...)
|
response.Feeds = append(response.Feeds, feeds...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUserProfileURL(userID, xsecToken string) string {
|
func makeUserProfileURL(userID, xsecToken string) string {
|
||||||
return fmt.Sprintf("https://www.xiaohongshu.com/user/profile/%s?xsec_token=%s&xsec_source=pc_note", userID, xsecToken)
|
return fmt.Sprintf("https://www.xiaohongshu.com/user/profile/%s?xsec_token=%s&xsec_source=pc_note", userID, xsecToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context) (*UserProfileResponse, error) {
|
||||||
|
page := u.page.Context(ctx)
|
||||||
|
|
||||||
|
// 创建导航动作
|
||||||
|
navigate := NewNavigate(page)
|
||||||
|
|
||||||
|
// 通过侧边栏导航到个人主页
|
||||||
|
if err := navigate.ToProfilePage(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to navigate to profile page via sidebar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待页面加载完成并获取 __INITIAL_STATE__
|
||||||
|
page.MustWaitStable()
|
||||||
|
|
||||||
|
return u.extractUserProfileData(page)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user