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:
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||
)
|
||||
|
||||
// FeedDetailAction 表示 Feed 详情页动作
|
||||
@@ -26,39 +28,37 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
|
||||
// 构建详情页 URL
|
||||
url := makeFeedDetailURL(feedID, xsecToken)
|
||||
|
||||
logrus.Infof("打开 feed 详情页: %s", url)
|
||||
|
||||
// 导航到详情页
|
||||
page.MustNavigate(url)
|
||||
page.MustWaitDOMStable()
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
||||
result := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__);
|
||||
if (window.__INITIAL_STATE__ &&
|
||||
window.__INITIAL_STATE__.note &&
|
||||
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||
const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap;
|
||||
return JSON.stringify(noteDetailMap);
|
||||
}
|
||||
return "";
|
||||
}`).String()
|
||||
|
||||
if result == "" {
|
||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
||||
return nil, errors.ErrNoFeedDetail
|
||||
}
|
||||
|
||||
// 定义响应结构并直接反序列化
|
||||
var initialState struct {
|
||||
Note struct {
|
||||
NoteDetailMap map[string]struct {
|
||||
Note FeedDetail `json:"note"`
|
||||
Comments CommentList `json:"comments"`
|
||||
} `json:"noteDetailMap"`
|
||||
} `json:"note"`
|
||||
var noteDetailMap map[string]struct {
|
||||
Note FeedDetail `json:"note"`
|
||||
Comments CommentList `json:"comments"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(result), &initialState); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
||||
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal noteDetailMap: %w", err)
|
||||
}
|
||||
|
||||
// 从 noteDetailMap 中获取对应 feedID 的数据
|
||||
noteDetail, exists := initialState.Note.NoteDetailMap[feedID]
|
||||
noteDetail, exists := noteDetailMap[feedID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID)
|
||||
}
|
||||
|
||||
@@ -7,17 +7,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||
)
|
||||
|
||||
type FeedsListAction struct {
|
||||
page *rod.Page
|
||||
}
|
||||
|
||||
// FeedsResult 定义页面初始状态结构
|
||||
type FeedsResult struct {
|
||||
Feed FeedData `json:"feed"`
|
||||
}
|
||||
|
||||
func NewFeedsListAction(page *rod.Page) *FeedsListAction {
|
||||
pp := page.Timeout(60 * time.Second)
|
||||
|
||||
@@ -33,24 +29,27 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) {
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
||||
result := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__);
|
||||
if (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 "";
|
||||
}`).String()
|
||||
|
||||
if result == "" {
|
||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
||||
return nil, errors.ErrNoFeeds
|
||||
}
|
||||
|
||||
// 解析完整的 InitialState
|
||||
var state FeedsResult
|
||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
||||
var feeds []Feed
|
||||
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||
}
|
||||
|
||||
// 返回 feed.feeds._value
|
||||
return state.Feed.Feeds.Value, nil
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
myerrors "github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||
)
|
||||
|
||||
// ActionResult 通用动作响应(点赞/收藏等)
|
||||
@@ -213,33 +214,33 @@ func (a *FavoriteAction) toggleFavorite(page *rod.Page, feedID string, targetCol
|
||||
|
||||
// getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态
|
||||
func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) {
|
||||
|
||||
result := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__);
|
||||
if (window.__INITIAL_STATE__ &&
|
||||
window.__INITIAL_STATE__.note &&
|
||||
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap);
|
||||
}
|
||||
return "";
|
||||
}`).String()
|
||||
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 {
|
||||
NoteDetailMap map[string]struct {
|
||||
Note struct {
|
||||
InteractInfo struct {
|
||||
Liked bool `json:"liked"`
|
||||
Collected bool `json:"collected"`
|
||||
} `json:"interactInfo"`
|
||||
} `json:"note"`
|
||||
} `json:"noteDetailMap"`
|
||||
InteractInfo struct {
|
||||
Liked bool `json:"liked"`
|
||||
Collected bool `json:"collected"`
|
||||
} `json:"interactInfo"`
|
||||
} `json:"note"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
||||
return false, false, errors.Wrap(err, "unmarshal __INITIAL_STATE__ failed")
|
||||
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||
return false, false, errors.Wrap(err, "unmarshal noteDetailMap failed")
|
||||
}
|
||||
|
||||
detail, ok := state.Note.NoteDetailMap[feedID]
|
||||
detail, ok := noteDetailMap[feedID]
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
pp := page.Timeout(180 * time.Second)
|
||||
pp := page.Timeout(300 * time.Second)
|
||||
|
||||
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -68,7 +68,15 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent
|
||||
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, "小红书发布失败")
|
||||
}
|
||||
|
||||
@@ -186,20 +194,25 @@ func uploadImages(page *rod.Page, imagesPaths []string) error {
|
||||
pp := page.Timeout(30 * time.Second)
|
||||
|
||||
// 验证文件路径有效性
|
||||
validPaths := make([]string, 0, len(imagesPaths))
|
||||
for _, path := range imagesPaths {
|
||||
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.MustSetFiles(imagesPaths...)
|
||||
uploadInput.MustSetFiles(validPaths...)
|
||||
|
||||
// 等待并验证上传完成
|
||||
return waitForUploadComplete(pp, len(imagesPaths))
|
||||
return waitForUploadComplete(pp, len(validPaths))
|
||||
}
|
||||
|
||||
// waitForUploadComplete 等待并验证上传完成
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
@@ -16,6 +17,144 @@ type SearchResult struct {
|
||||
} `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 {
|
||||
page *rod.Page
|
||||
}
|
||||
@@ -26,7 +165,7 @@ func NewSearchAction(page *rod.Page) *SearchAction {
|
||||
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)
|
||||
|
||||
searchURL := makeSearchURL(keyword)
|
||||
@@ -35,24 +174,69 @@ func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, erro
|
||||
|
||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||
|
||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
||||
result := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__);
|
||||
// 如果有筛选条件,则应用筛选
|
||||
if len(filters) > 0 {
|
||||
// 将所有 FilterOption 转换为内部筛选选项
|
||||
var allInternalFilters []internalFilterOption
|
||||
for _, filter := range filters {
|
||||
internalFilters, err := convertToInternalFilters(filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("筛选选项转换失败: %w", err)
|
||||
}
|
||||
return "";
|
||||
}`).String()
|
||||
allInternalFilters = append(allInternalFilters, internalFilters...)
|
||||
}
|
||||
|
||||
// 验证所有内部筛选选项
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
||||
return nil, errors.ErrNoFeeds
|
||||
}
|
||||
|
||||
var searchResult SearchResult
|
||||
if err := json.Unmarshal([]byte(result), &searchResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
||||
var feeds []Feed
|
||||
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||
}
|
||||
|
||||
return searchResult.Search.Feeds.Value, nil
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func makeSearchURL(keyword string) string {
|
||||
@@ -61,5 +245,7 @@ func makeSearchURL(keyword string) string {
|
||||
values.Set("keyword", keyword)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ func TestSearch(t *testing.T) {
|
||||
defer b.Close()
|
||||
|
||||
page := b.NewPage()
|
||||
defer page.Close()
|
||||
defer func() {
|
||||
_ = page.Close()
|
||||
}()
|
||||
|
||||
action := NewSearchAction(page)
|
||||
|
||||
@@ -32,3 +34,72 @@ func TestSearch(t *testing.T) {
|
||||
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.MustWaitStable()
|
||||
|
||||
return u.extractUserProfileData(page)
|
||||
}
|
||||
|
||||
// extractUserProfileData 从页面中提取用户资料数据的通用方法
|
||||
func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) {
|
||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||
|
||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
||||
result := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__) {
|
||||
return JSON.stringify(window.__INITIAL_STATE__);
|
||||
userDataResult := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__ &&
|
||||
window.__INITIAL_STATE__.user &&
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
||||
if userDataResult == "" {
|
||||
return nil, fmt.Errorf("user.userPageData.value not found in __INITIAL_STATE__")
|
||||
}
|
||||
// 定义响应结构并直接反序列化
|
||||
var initialState = struct {
|
||||
User struct {
|
||||
UserPageData UserPageData `json:"userPageData"`
|
||||
Notes struct {
|
||||
Feeds [][]Feed `json:"_rawValue"` // 帖子为双重数组
|
||||
} `json:"notes"`
|
||||
} `json:"user"`
|
||||
}{}
|
||||
if err := json.Unmarshal([]byte(result), &initialState); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
||||
|
||||
// 2. 获取用户帖子:window.__INITIAL_STATE__.user.notes.value
|
||||
notesResult := page.MustEval(`() => {
|
||||
if (window.__INITIAL_STATE__ &&
|
||||
window.__INITIAL_STATE__.user &&
|
||||
window.__INITIAL_STATE__.user.notes) {
|
||||
const notes = window.__INITIAL_STATE__.user.notes;
|
||||
// 优先使用 value(getter),如果不存在则使用 _value(内部字段)
|
||||
const data = notes.value !== undefined ? notes.value : notes._value;
|
||||
if (data) {
|
||||
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{
|
||||
UserBasicInfo: initialState.User.UserPageData.RawValue.BasicInfo,
|
||||
Interactions: initialState.User.UserPageData.RawValue.Interactions,
|
||||
UserBasicInfo: userPageData.BasicInfo,
|
||||
Interactions: userPageData.Interactions,
|
||||
}
|
||||
// 添加用户贴子
|
||||
for _, feeds := range initialState.User.Notes.Feeds {
|
||||
|
||||
// 添加用户帖子(展平双重数组)
|
||||
for _, feeds := range notesFeeds {
|
||||
if len(feeds) != 0 {
|
||||
response.Feeds = append(response.Feeds, feeds...)
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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