- Updated the PostComment method to include error handling for navigation and element interactions. - Replaced sleep calls with more reliable wait mechanisms to ensure page stability. - Added checks for the presence of input elements and improved logging for better debugging.
431 lines
9.9 KiB
Go
431 lines
9.9 KiB
Go
package xiaohongshu
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"math/rand"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/input"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// 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(180 * time.Second)
|
|
|
|
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
|
time.Sleep(1 * time.Second)
|
|
|
|
if err := mustClickPublishTab(page, "上传图文"); err != nil {
|
|
logrus.Errorf("点击上传图文 TAB 失败: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
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("图片不能为空")
|
|
}
|
|
|
|
trimmedContent := strings.TrimRightFunc(content.Content, unicode.IsSpace)
|
|
if utf8.RuneCountInString(trimmedContent) > 1000 {
|
|
return errors.New("正文内容不能超过1000个字符")
|
|
}
|
|
|
|
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 removePopCover(page *rod.Page) {
|
|
|
|
// 先移除弹窗封面
|
|
has, elem, err := page.Has("div.d-popover")
|
|
if err != nil {
|
|
return
|
|
}
|
|
if has {
|
|
elem.MustRemove()
|
|
}
|
|
|
|
// 兜底:点击一下空位置吧
|
|
clickEmptyPosition(page)
|
|
}
|
|
|
|
func clickEmptyPosition(page *rod.Page) {
|
|
x := 380 + rand.Intn(100)
|
|
y := 20 + rand.Intn(60)
|
|
page.Mouse.MustMoveTo(float64(x), float64(y)).MustClick(proto.InputMouseButtonLeft)
|
|
}
|
|
|
|
func mustClickPublishTab(page *rod.Page, tabname string) error {
|
|
page.MustElement(`div.upload-content`).MustWaitVisible()
|
|
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
tab, blocked, err := getTabElement(page, tabname)
|
|
if err != nil {
|
|
logrus.Warnf("获取发布 TAB 元素失败: %v", err)
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
if tab == nil {
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
if blocked {
|
|
logrus.Info("发布 TAB 被遮挡,尝试移除遮挡")
|
|
removePopCover(page)
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
if err := tab.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
|
logrus.Warnf("点击发布 TAB 失败: %v", err)
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return errors.Errorf("没有找到发布 TAB - %s", tabname)
|
|
}
|
|
|
|
func getTabElement(page *rod.Page, tabname string) (*rod.Element, bool, error) {
|
|
elems, err := page.Elements("div.creator-tab")
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
for _, elem := range elems {
|
|
if !isElementVisible(elem) {
|
|
continue
|
|
}
|
|
|
|
text, err := elem.Text()
|
|
if err != nil {
|
|
logrus.Debugf("获取发布 TAB 文本失败: %v", err)
|
|
continue
|
|
}
|
|
|
|
if strings.TrimSpace(text) != tabname {
|
|
continue
|
|
}
|
|
|
|
blocked, err := isElementBlocked(elem)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
return elem, blocked, nil
|
|
}
|
|
|
|
return nil, false, nil
|
|
}
|
|
|
|
func isElementBlocked(elem *rod.Element) (bool, error) {
|
|
result, err := elem.Eval(`() => {
|
|
const rect = this.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) {
|
|
return true;
|
|
}
|
|
const x = rect.left + rect.width / 2;
|
|
const y = rect.top + rect.height / 2;
|
|
const target = document.elementFromPoint(x, y);
|
|
return !(target === this || this.contains(target));
|
|
}`)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return result.Value.Bool(), nil
|
|
}
|
|
|
|
func uploadImages(page *rod.Page, imagesPaths []string) error {
|
|
pp := page.Timeout(30 * time.Second)
|
|
|
|
// 验证文件路径有效性
|
|
for _, path := range imagesPaths {
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return errors.Wrapf(err, "图片文件不存在: %s", path)
|
|
}
|
|
}
|
|
|
|
// 等待上传输入框出现
|
|
uploadInput := pp.MustElement(".upload-input")
|
|
|
|
// 上传多个文件
|
|
uploadInput.MustSetFiles(imagesPaths...)
|
|
|
|
// 等待并验证上传完成
|
|
return waitForUploadComplete(pp, len(imagesPaths))
|
|
}
|
|
|
|
// waitForUploadComplete 等待并验证上传完成
|
|
func waitForUploadComplete(page *rod.Page, expectedCount int) error {
|
|
maxWaitTime := 60 * time.Second
|
|
checkInterval := 500 * time.Millisecond
|
|
start := time.Now()
|
|
|
|
slog.Info("开始等待图片上传完成", "expected_count", expectedCount)
|
|
|
|
for time.Since(start) < maxWaitTime {
|
|
// 使用具体的pr类名检查已上传的图片
|
|
uploadedImages, err := page.Elements(".img-preview-area .pr")
|
|
|
|
slog.Info("uploadedImages", "uploadedImages", uploadedImages)
|
|
|
|
if err == nil {
|
|
currentCount := len(uploadedImages)
|
|
slog.Info("检测到已上传图片", "current_count", currentCount, "expected_count", expectedCount)
|
|
if currentCount >= expectedCount {
|
|
slog.Info("所有图片上传完成", "count", currentCount)
|
|
return nil
|
|
}
|
|
} else {
|
|
slog.Debug("未找到已上传图片元素")
|
|
}
|
|
|
|
time.Sleep(checkInterval)
|
|
}
|
|
|
|
return errors.New("上传超时,请检查网络连接和图片大小")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// isElementVisible 检查元素是否可见
|
|
func isElementVisible(elem *rod.Element) bool {
|
|
|
|
// 检查是否有隐藏样式
|
|
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
|
|
}
|
|
}
|
|
|
|
visible, err := elem.Visible()
|
|
if err != nil {
|
|
slog.Warn("无法获取元素可见性", "error", err)
|
|
return true
|
|
}
|
|
|
|
return visible
|
|
}
|