package xiaohongshu import ( "context" "log/slog" "strings" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/input" "github.com/go-rod/rod/lib/proto" "github.com/pkg/errors" ) // PublishImageContent 发布图文内容 type PublishImageContent struct { Title string Content string Tags []string ImagePaths []string } type PublishAction struct { page *rod.Page } const ( urlOfPublic = `https://creator.xiaohongshu.com/publish/publish?source=official` ) func NewPublishImageAction(page *rod.Page) (*PublishAction, error) { pp := page.Timeout(60 * time.Second) pp.MustNavigate(urlOfPublic) pp.MustElement(`div.upload-content`).MustWaitVisible() slog.Info("wait for upload-content visible success") // 蜜罐元素防御检测 if err := detectHoneypotElements(pp); err != nil { return nil, errors.Wrap(err, "蜜罐元素检测失败") } // 等待一段时间确保页面完全加载 time.Sleep(1 * time.Second) createElems := pp.MustElements("div.creator-tab") slog.Info("foundcreator-tab elements", "count", len(createElems)) // 蜜罐元素防御:过滤掉隐藏的元素 var visibleElems []*rod.Element for _, elem := range createElems { if isElementVisible(elem) { visibleElems = append(visibleElems, elem) } } slog.Info("filtered visible creator-tab elements", "count", len(visibleElems)) for _, elem := range visibleElems { text, err := elem.Text() if err != nil { slog.Error("获取元素文本失败", "error", err) continue } if text == "上传图文" { if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil { slog.Error("点击元素失败", "error", err) continue } break } } time.Sleep(1 * time.Second) return &PublishAction{ page: pp, }, nil } func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent) error { if len(content.ImagePaths) == 0 { return errors.New("图片不能为空") } page := p.page.Context(ctx) if err := uploadImages(page, content.ImagePaths); err != nil { return errors.Wrap(err, "小红书上传图片失败") } if err := submitPublish(page, content.Title, content.Content, content.Tags); err != nil { return errors.Wrap(err, "小红书发布失败") } return nil } func uploadImages(page *rod.Page, imagesPaths []string) error { pp := page.Timeout(30 * time.Second) // 等待上传输入框出现 uploadInput := pp.MustElement(".upload-input") // 上传多个文件 uploadInput.MustSetFiles(imagesPaths...) // 等待上传完成 time.Sleep(3 * time.Second) return nil } func submitPublish(page *rod.Page, title, content string, tags []string) error { titleElem := page.MustElement("div.d-input input") titleElem.MustInput(title) time.Sleep(1 * time.Second) if contentElem, ok := getContentElement(page); ok { contentElem.MustInput(content) inputTags(contentElem, tags) } else { return errors.New("没有找到内容输入框") } time.Sleep(1 * time.Second) submitButton := page.MustElement("div.submit div.d-button-content") submitButton.MustClick() time.Sleep(3 * time.Second) return nil } // 查找内容输入框 - 使用Race方法处理两种样式 func getContentElement(page *rod.Page) (*rod.Element, bool) { var foundElement *rod.Element var found bool page.Race(). Element("div.ql-editor").MustHandle(func(e *rod.Element) { foundElement = e found = true }). ElementFunc(func(page *rod.Page) (*rod.Element, error) { return findTextboxByPlaceholder(page) }).MustHandle(func(e *rod.Element) { foundElement = e found = true }). MustDo() if found { return foundElement, true } slog.Warn("no content element found by any method") return nil, false } func inputTags(contentElem *rod.Element, tags []string) { if len(tags) == 0 { return } time.Sleep(1 * time.Second) for i := 0; i < 20; i++ { contentElem.MustKeyActions(). Type(input.ArrowDown). MustDo() time.Sleep(10 * time.Millisecond) } contentElem.MustKeyActions(). Press(input.Enter). Press(input.Enter). MustDo() time.Sleep(1 * time.Second) for _, tag := range tags { tag = strings.TrimLeft(tag, "#") inputTag(contentElem, tag) } } func inputTag(contentElem *rod.Element, tag string) { contentElem.MustInput("#") time.Sleep(200 * time.Millisecond) for _, char := range tag { contentElem.MustInput(string(char)) time.Sleep(50 * time.Millisecond) } time.Sleep(1 * time.Second) page := contentElem.Page() topicContainer, err := page.Element("#creator-editor-topic-container") if err == nil && topicContainer != nil { firstItem, err := topicContainer.Element(".item") if err == nil && firstItem != nil { firstItem.MustClick() slog.Info("成功点击标签联想选项", "tag", tag) time.Sleep(200 * time.Millisecond) } else { slog.Warn("未找到标签联想选项,直接输入空格", "tag", tag) // 如果没有找到联想选项,输入空格结束 contentElem.MustInput(" ") } } else { slog.Warn("未找到标签联想下拉框,直接输入空格", "tag", tag) // 如果没有找到下拉框,输入空格结束 contentElem.MustInput(" ") } time.Sleep(500 * time.Millisecond) // 等待标签处理完成 } func findTextboxByPlaceholder(page *rod.Page) (*rod.Element, error) { elements := page.MustElements("p") if elements == nil { return nil, errors.New("no p elements found") } // 查找包含指定placeholder的元素 placeholderElem := findPlaceholderElement(elements, "输入正文描述") if placeholderElem == nil { return nil, errors.New("no placeholder element found") } // 向上查找textbox父元素 textboxElem := findTextboxParent(placeholderElem) if textboxElem == nil { return nil, errors.New("no textbox parent found") } return textboxElem, nil } func findPlaceholderElement(elements []*rod.Element, searchText string) *rod.Element { for _, elem := range elements { placeholder, err := elem.Attribute("data-placeholder") if err != nil || placeholder == nil { continue } if strings.Contains(*placeholder, searchText) { return elem } } return nil } func findTextboxParent(elem *rod.Element) *rod.Element { currentElem := elem for i := 0; i < 5; i++ { parent, err := currentElem.Parent() if err != nil { break } role, err := parent.Attribute("role") if err != nil || role == nil { currentElem = parent continue } if *role == "textbox" { return parent } currentElem = parent } return nil } // detectHoneypotElements 检测页面中的蜜罐元素 func detectHoneypotElements(page *rod.Page) error { // 检查是否存在隐藏的creator-tab元素(蜜罐元素) elements := page.MustElements("div.creator-tab") hiddenCount := 0 for _, elem := range elements { if !isElementVisible(elem) { hiddenCount++ slog.Info("检测到隐藏的creator-tab元素(蜜罐元素)") } } if hiddenCount > 0 { slog.Info("蜜罐元素检测完成", "隐藏元素数量", hiddenCount) } return nil } // isElementVisible 检查元素是否可见(非蜜罐元素) func isElementVisible(elem *rod.Element) bool { // 检查style属性中是否包含隐藏样式 style, err := elem.Attribute("style") if err == nil && style != nil { styleStr := *style // 检查是否被移到屏幕外(常见的蜜罐技术) if strings.Contains(styleStr, "left: -9999px") || strings.Contains(styleStr, "top: -9999px") || strings.Contains(styleStr, "position: absolute; left: -9999px") || strings.Contains(styleStr, "display: none") || strings.Contains(styleStr, "visibility: hidden") { return false } } // 检查computed style visible, err := elem.Visible() if err != nil { slog.Warn("无法获取元素可见性", "error", err) return true // 如果无法确定,默认认为可见 } return visible }