332 lines
7.7 KiB
Go
332 lines
7.7 KiB
Go
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
|
||
}
|