Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions mcp_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,36 @@ func (s *AppServer) handleReplyComment(ctx context.Context, args map[string]inte
}},
}
}

func (s *AppServer) handleResolveShortLink(ctx context.Context, args ResolveShortLinkArgs) *MCPToolResult {
logrus.Info("MCP: 解析短链接")

if args.URL == "" {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "解析失败: 缺少短链接URL参数"}},
IsError: true,
}
}

logrus.Infof("MCP: 解析短链接 - URL: %s", args.URL)

result, err := s.xiaohongshuService.ResolveShortLink(ctx, args.URL)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "解析短链接失败: " + err.Error()}},
IsError: true,
}
}

jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: fmt.Sprintf("解析成功但序列化失败: %v", err)}},
IsError: true,
}
}

return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: string(jsonData)}},
}
}
23 changes: 22 additions & 1 deletion mcp_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ type FavoriteFeedArgs struct {
Unfavorite bool `json:"unfavorite,omitempty" jsonschema:"是否取消收藏,true为取消收藏,false或未设置则为收藏"`
}

// ResolveShortLinkArgs 解析短链接参数
type ResolveShortLinkArgs struct {
URL string `json:"url" jsonschema:"小红书分享短链接,如 https://xhslink.com/o/xxx 或 xhslink.com/xxx"`
}

// InitMCPServer 初始化 MCP Server
func InitMCPServer(appServer *AppServer) *mcp.Server {
// 创建 MCP Server
Expand Down Expand Up @@ -443,7 +448,23 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}),
)

logrus.Infof("Registered %d MCP tools", 13)
// 工具 14: 解析短链接
mcp.AddTool(server,
&mcp.Tool{
Name: "resolve_shortlink",
Description: "解析小红书分享短链接(如 xhslink.com/xxx),提取 feed_id 和 xsec_token,可直接用于获取笔记详情",
Annotations: &mcp.ToolAnnotations{
Title: "Resolve Short Link",
ReadOnlyHint: true,
},
},
withPanicRecovery("resolve_shortlink", func(ctx context.Context, req *mcp.CallToolRequest, args ResolveShortLinkArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleResolveShortLink(ctx, args)
return convertToMCPResult(result), nil, nil
}),
)

logrus.Infof("Registered %d MCP tools", 14)
}

// convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式
Expand Down
5 changes: 5 additions & 0 deletions service.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,11 @@ func (s *XiaohongshuService) ReplyCommentToFeed(ctx context.Context, feedID, xse
}, nil
}

// ResolveShortLink 解析小红书短链接,提取 feed_id 和 xsec_token
func (s *XiaohongshuService) ResolveShortLink(ctx context.Context, shortURL string) (*xiaohongshu.ShortLinkResult, error) {
return xiaohongshu.ResolveShortLink(ctx, shortURL)
}

func newBrowser() *headless_browser.Browser {
return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath()))
}
Expand Down
10 changes: 5 additions & 5 deletions xiaohongshu/comment_feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element,
// === 2. 获取当前评论数量 ===
currentCount := getCommentCount(page)
logrus.Infof("当前评论数: %d", currentCount)

if currentCount != lastCommentCount {
logrus.Infof("✓ 评论数增加: %d -> %d", lastCommentCount, currentCount)
lastCommentCount = currentCount
Expand All @@ -202,7 +202,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element,
// === 4. 先滚动到最后一个评论(触发懒加载)===
if currentCount > 0 {
logrus.Infof("滚动到最后一个评论(共 %d 条)", currentCount)

// 使用 Go 获取所有评论元素
elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment, .comment-item, .comment")
if err == nil && len(elements) > 0 {
Expand Down Expand Up @@ -231,7 +231,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element,
if commentID != "" {
selector := fmt.Sprintf("#comment-%s", commentID)
logrus.Infof("尝试通过 commentID 查找: %s", selector)

// 使用 Timeout 避免长时间等待
el, err := page.Timeout(2 * time.Second).Element(selector)
if err == nil && el != nil {
Expand All @@ -244,7 +244,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element,
// 通过 userID 查找
if userID != "" {
logrus.Infof("尝试通过 userID 查找: %s", userID)

// 使用 Timeout 避免长时间等待
elements, err := page.Timeout(2 * time.Second).Elements(".comment-item, .comment, .parent-comment")
if err == nil && len(elements) > 0 {
Expand All @@ -262,7 +262,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element,
logrus.Infof("获取评论元素失败或超时: %v", err)
}
}

logrus.Infof("本次尝试未找到目标评论,继续下一轮...")

// === 7. 等待内容加载 ===
Expand Down
181 changes: 181 additions & 0 deletions xiaohongshu/shortlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package xiaohongshu

import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"

"github.com/sirupsen/logrus"
)

// ShortLinkResult 短链接解析结果
type ShortLinkResult struct {
FeedID string `json:"feed_id"`
XsecToken string `json:"xsec_token"`
OriginalURL string `json:"original_url"`
RedirectURL string `json:"redirect_url"`
ShareID string `json:"share_id,omitempty"`
AppPlatform string `json:"app_platform,omitempty"`
XsecSource string `json:"xsec_source,omitempty"`
}

// 短链接域名白名单
var shortLinkDomains = []string{
"xhslink.com",
}

// feedID 提取正则(包级变量,避免重复编译)
var feedIDPatterns = []*regexp.Regexp{
regexp.MustCompile(`/discovery/item/([a-zA-Z0-9]+)`),
regexp.MustCompile(`/explore/([a-zA-Z0-9]+)`),
regexp.MustCompile(`/note/([a-zA-Z0-9]+)`),
}

// 短链接专用 HTTP Client(复用连接池)
var shortLinkClient = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// 禁止自动跟随重定向,需手动获取 Location header
return http.ErrUseLastResponse
},
}

// 移动端 User-Agent,模拟 iOS Chrome 访问
const mobileUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1"

// ResolveShortLink 解析小红书短链接,提取 feed_id 和 xsec_token
func ResolveShortLink(ctx context.Context, shortURL string) (*ShortLinkResult, error) {
// 标准化 URL
normalizedURL, err := normalizeShortLinkURL(shortURL)
if err != nil {
return nil, err
}

logrus.Infof("解析短链接: %s", normalizedURL)

req, err := http.NewRequestWithContext(ctx, "GET", normalizedURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}

// 设置移动端 Headers(关键!小红书会根据 UA 返回不同响应)
req.Header.Set("User-Agent", mobileUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh-Hans;q=0.9")

// 发送请求
resp, err := shortLinkClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求短链接失败: %w", err)
}
defer resp.Body.Close()

// 检查是否为重定向响应 (301, 302, 307, 308)
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently &&
resp.StatusCode != http.StatusTemporaryRedirect && resp.StatusCode != http.StatusPermanentRedirect {
return nil, fmt.Errorf("短链接未返回重定向,状态码: %d", resp.StatusCode)
}

// 获取重定向 URL
location := resp.Header.Get("Location")
if location == "" {
return nil, fmt.Errorf("短链接响应中缺少 Location header")
}

logrus.Infof("重定向到: %s", location)

// 解析重定向 URL 提取参数
return parseRedirectURL(location, normalizedURL)
}

// normalizeShortLinkURL 标准化短链接 URL
func normalizeShortLinkURL(input string) (string, error) {
input = strings.TrimSpace(input)

// 如果不是 URL 格式,尝试添加协议
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
// 检查是否是短链接格式 (xhslink.com/xxx)
for _, domain := range shortLinkDomains {
if strings.HasPrefix(input, domain) {
input = "https://" + input
break
}
}
}

// 解析 URL
parsedURL, err := url.Parse(input)
if err != nil {
return "", fmt.Errorf("无效的 URL 格式: %w", err)
}

// 验证是否为支持的短链接域名
isValidDomain := false
for _, domain := range shortLinkDomains {
if parsedURL.Host == domain || strings.HasSuffix(parsedURL.Host, "."+domain) {
isValidDomain = true
break
}
}

if !isValidDomain {
return "", fmt.Errorf("不支持的短链接域名: %s,支持的域名: %v", parsedURL.Host, shortLinkDomains)
}

// 确保使用 HTTPS
parsedURL.Scheme = "https"

return parsedURL.String(), nil
}

// parseRedirectURL 解析重定向 URL,提取 feed_id 和 xsec_token
func parseRedirectURL(redirectURL, originalURL string) (*ShortLinkResult, error) {
parsedURL, err := url.Parse(redirectURL)
if err != nil {
return nil, fmt.Errorf("解析重定向 URL 失败: %w", err)
}

// 从路径中提取 feed_id
// 格式: /discovery/item/{feed_id} 或 /explore/{feed_id}
feedID := extractFeedIDFromPath(parsedURL.Path)
if feedID == "" {
return nil, fmt.Errorf("无法从重定向 URL 提取 feed_id: %s", redirectURL)
}

// 从查询参数中提取 xsec_token
query := parsedURL.Query()
xsecToken := query.Get("xsec_token")
if xsecToken == "" {
return nil, fmt.Errorf("无法从重定向 URL 提取 xsec_token: %s", redirectURL)
}

result := &ShortLinkResult{
FeedID: feedID,
XsecToken: xsecToken,
OriginalURL: originalURL,
RedirectURL: redirectURL,
ShareID: query.Get("share_id"),
AppPlatform: query.Get("app_platform"),
XsecSource: query.Get("xsec_source"),
}

logrus.Infof("解析成功: feed_id=%s", feedID)

return result, nil
}

// extractFeedIDFromPath 从 URL 路径中提取 feed_id
func extractFeedIDFromPath(path string) string {
for _, pattern := range feedIDPatterns {
matches := pattern.FindStringSubmatch(path)
if len(matches) > 1 {
return matches[1]
}
}

return ""
}