geoGo/internal/publisher/base.go

523 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package publisher
import (
"context"
"encoding/json"
"fmt"
"geo/internal/entitys"
"geo/pkg"
"geo/utils/utils_oss"
"log"
"os"
"path/filepath"
"strings"
"time"
"geo/internal/config"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
type BasePublisher struct {
ctx context.Context
Headless bool
Title string
Content string
Tags []string
UserIndex string
PlatIndex string
RequestID string
ImagePath string
SourcePath string
Browser *rod.Browser
Page *rod.Page
Logger *log.Logger
LogFile *os.File
LoginURL string
EditorURL string
LoginedURL string
CookiesFile string
PlatInfo *entitys.PublishTaskDetail
config *config.Config
MaxRetries int
RetryDelay int
ossClient *utils_oss.Client
}
// taskParams 任务参数结构体
type TaskParams struct {
Headless bool
Title string
TagRaw string
UserIndex string
PlatIndex string
RequestID string
ImagePath string
SourcePath string
Content string
Tags []string
PublishData *entitys.PublishTaskDetail
}
// NewBasePublisher 构造函数,增加 logger 参数
func NewBasePublisher(ctx context.Context, task *TaskParams, config *config.Config, logger *log.Logger) *BasePublisher {
var baseLogger *log.Logger
var logFile *os.File
if logger != nil {
// 使用传入的logger
baseLogger = logger
logFile = nil
} else {
// 兼容旧逻辑
logsDir := config.Sys.LogsDir
if logsDir == "" {
logsDir = "./logs"
}
os.MkdirAll(logsDir, 0755)
logFile, _ = os.Create(filepath.Join(logsDir, task.RequestID+".log"))
baseLogger = log.New(logFile, "", log.LstdFlags)
}
base := &BasePublisher{
ctx: ctx,
Headless: task.Headless,
Title: task.Title,
Content: task.Content,
Tags: task.Tags,
UserIndex: task.UserIndex,
PlatIndex: task.PlatIndex,
RequestID: task.RequestID,
ImagePath: task.ImagePath,
SourcePath: task.SourcePath,
Logger: baseLogger,
LogFile: logFile,
PlatInfo: task.PublishData,
config: config,
LoginURL: task.PublishData.LoginUrl,
EditorURL: task.PublishData.EditUrl,
LoginedURL: task.PublishData.LoginedUrl,
MaxRetries: 3,
RetryDelay: 200,
}
base.CookiesFile = filepath.Join(base.cookiesDir(), task.PlatIndex+".json")
return base
}
func (b *BasePublisher) cookiesDir() string {
dir := filepath.Join(b.config.Sys.CookiesDir, b.UserIndex)
os.MkdirAll(dir, 0755)
return dir
}
func (b *BasePublisher) SetupDriver() error {
b.LogInfo("初始化浏览器。。。。")
userDataDir := filepath.Join(b.config.Sys.ChromeDataDir, b.UserIndex, b.RequestID+fmt.Sprintf("___%d", time.Now().UnixNano()))
//userDataDir := fmt.Sprintf("./chrome_data/user_%d_", time.Now().UnixNano())
os.MkdirAll(userDataDir, 0755)
l := launcher.New().
Bin(b.config.Sys.ChromePath).
UserDataDir(userDataDir).
Headless(b.Headless).
Leakless(false).Set("disable-blink-features", "AutomationControlled")
if b.Headless {
// 无头模式专用参数
l.Set("headless", "new") // 使用新版无头模式
l.Set("disable-gpu")
l.Set("no-sandbox")
l.Set("disable-dev-shm-usage")
} else {
l.Set("window-size", "1920,1080")
l.Set("start-maximized")
// 移除 headless 相关参数
l.Delete("headless")
}
// 设置用户数据目录
l.UserDataDir(userDataDir)
// 关键优化:不重新使用已有的数据目录时不要清除
//l.Set("profile-directory", "Default")
// 设置 Chrome 启动参数
//l.Set("no-sandbox")
//l.Set("disable-dev-shm-usage")
// 关键:禁用后台限制,让页面在后台也能正常执行
//l.Set("disable-background-timer-throttling")
//l.Set("disable-backgrounding-occluded-windows")
//l.Set("disable-renderer-backgrounding")
//l.Set("disable-ipc-flooding-protection")
// 窗口大小
l.Set("window-size", "1920,1080")
l.Set("lang", "zh-CN")
l.Set("force-device-scale-factor", "1")
url, err := l.Launch()
if err != nil {
return fmt.Errorf("启动浏览器失败: %v", err)
}
b.Browser = rod.New().Context(b.ctx).ControlURL(url).MustConnect()
b.Page = b.Browser.MustPage()
return nil
}
func (b *BasePublisher) Close() {
if b.Page != nil {
b.Page.Close()
}
if b.Browser != nil {
b.Browser.Close()
}
if b.LogFile != nil {
b.LogFile.Close()
}
}
func (b *BasePublisher) SaveCookies() error {
cookies, err := b.Page.Cookies(nil)
if err != nil {
return err
}
data, err := json.Marshal(cookies)
if err != nil {
return err
}
os.WriteFile(b.CookiesFile, data, 0644)
if err != nil {
return err
}
return b.uploadToOss(data)
}
func (b *BasePublisher) LoadCookies() error {
var data []byte
if _, err := os.Stat(b.CookiesFile); os.IsNotExist(err) {
_err := b.getCookieFromUrl()
if _err != nil {
return _err
}
}
data, err := os.ReadFile(b.CookiesFile)
if err != nil {
return err
}
var cookies []*proto.NetworkCookieParam
if err := json.Unmarshal(data, &cookies); err != nil {
return err
}
return b.Page.SetCookies(cookies)
}
func (b *BasePublisher) DelCookies() error {
err := os.Remove(b.CookiesFile)
if err != nil {
return err
}
return nil
}
func (b *BasePublisher) RefreshPage() error {
_, err := b.Page.Eval(`() => location.reload()`)
return err
}
func (b *BasePublisher) WaitForPageReady(timeout int) error {
return b.Page.Context(b.ctx).WaitLoad()
}
func (b *BasePublisher) WaitForElement(selector string, timeout int) (*rod.Element, error) {
return b.Page.Context(b.ctx).Element(selector)
}
func (b *BasePublisher) WaitForElementVisible(selector string, timeout int) (*rod.Element, error) {
el, err := b.WaitForElement(selector, timeout)
if err != nil {
return nil, err
}
if err := el.WaitVisible(); err != nil {
return nil, err
}
return el, nil
}
func (b *BasePublisher) WaitForElementClickable(selector string, timeout int) (*rod.Element, error) {
el, err := b.WaitForElementVisible(selector, timeout)
if err != nil {
return nil, err
}
if err := el.WaitVisible(); err != nil {
return nil, err
}
return el, nil
}
func (b *BasePublisher) JSClick(element *rod.Element) error {
if element == nil {
b.Logger.Printf("element is nil")
return fmt.Errorf("element is nil")
}
err := element.Click(proto.InputMouseButtonLeft, 1)
if err != nil {
b.Logger.Printf("click fail:" + err.Error())
}
return err
}
func (b *BasePublisher) JSClick2(element *rod.Element) error {
if element == nil {
b.Logger.Printf("element is nil")
return fmt.Errorf("element is nil")
}
err := element.Click(proto.InputMouseButtonLeft, 1)
if err != nil {
b.Logger.Printf("click fail:" + err.Error())
}
return err
}
func (b *BasePublisher) ScrollToElement(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => el.scrollIntoView({block: 'center', behavior: 'smooth'})`,
})
return err
}
func (b *BasePublisher) Sleep(seconds int) {
time.Sleep(time.Duration(seconds) * time.Second)
}
func (b *BasePublisher) LogStep(stepName string, success bool, message string) {
if success {
b.Logger.Printf("✅ %s: 成功 %s", stepName, message)
} else {
b.Logger.Printf("❌ %s: 失败 %s", stepName, message)
}
}
func (b *BasePublisher) LogInfo(message string) {
b.Logger.Printf("📌 %s", message)
}
func (b *BasePublisher) LogInfof(message string, arg ...any) {
b.Logger.Printf("📌 "+message, arg)
}
func (b *BasePublisher) LogError(message string) {
b.Logger.Printf("❌ %s", message)
}
func (b *BasePublisher) GetCurrentURL() string {
info := b.Page.MustInfo()
return info.URL
}
func (b *BasePublisher) Screenshot(filename string) error {
data, err := b.Page.Screenshot(false, nil)
if err != nil {
return err
}
return os.WriteFile(filename, data, 0644)
}
// 抽象方法 - 子类需要实现
func (b *BasePublisher) WaitLogin() (bool, string) {
return false, "需要实现"
}
func (p *BasePublisher) CheckLoginStatus() bool {
currentURL := p.GetCurrentURL()
if strings.Contains(currentURL, p.LoginURL) {
return false
}
return true
}
func (b *BasePublisher) PublishNote() (bool, string) {
return false, "需要实现"
}
// ClearContentEditable 清空 contenteditable 元素的内容
func (b *BasePublisher) ClearContentEditable(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => { el.innerText = ''; el.innerHTML = ''; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
})
return err
}
// SetContentEditable 设置 contenteditable 元素的内容
func (b *BasePublisher) SetContentEditable(element *rod.Element, content string) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `(el, val) => { el.innerText = val; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
JSArgs: []interface{}{content},
})
return err
}
// SetInputValue 设置输入框值并触发事件
func (b *BasePublisher) SetInputValue(element *rod.Element, value string) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `(el, val) => { el.value = val; el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true})); }`,
JSArgs: []interface{}{value},
})
return err
}
// ClearInput 清空输入框
func (b *BasePublisher) ClearInput(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => { el.value = ''; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
})
return err
}
// SleepMs 毫秒级等待
func (b *BasePublisher) SleepMs(milliseconds int) {
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
}
// StartNote 开始任务日志
func (b *BasePublisher) StartNote() {
b.LogInfo(strings.Repeat("=", 50))
b.LogInfo(fmt.Sprintf("开始执行:%s", b.PlatIndex))
b.LogInfo(fmt.Sprintf("标题: %s", b.Title))
b.LogInfo(fmt.Sprintf("标签: %v", b.Tags))
b.LogInfo(strings.Repeat("=", 50))
}
// InitPage 初始化页面
func (p *BasePublisher) InitPage() error {
// 尝试加载cookies并检查登录状态
if err := p.LoadCookies(); err == nil {
p.Page.MustNavigate(p.EditorURL)
p.WaitForPageReady(5)
p.Sleep(2)
}
// 统一检查登录状态
if !p.CheckLoginStatus() {
p.LogInfo("未登录或登录已过期,需要重新登录")
return fmt.Errorf("需要登录")
}
p.SaveCookies()
return nil
}
// SafeElement 安全地获取元素,如果不存在立即返回 nil 而不阻塞
func (b *BasePublisher) SafeElement(selector string) (*rod.Element, error) {
// 先检查是否存在
exists, _, err := b.Page.Has(selector)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
// 存在再获取,不会阻塞
return b.Page.Element(selector)
}
// SafeElementInParent 在父元素中安全查找
func (b *BasePublisher) SafeElementInParent(parent *rod.Element, selector string) (*rod.Element, error) {
// 使用 Elements 获取所有子元素(非阻塞)
children, err := parent.Elements(selector)
if err != nil {
return nil, err
}
if len(children) == 0 {
return nil, nil
}
return children[0], nil
}
// RenameDocument 将指定绝对路径的文档更名为新名称(不含后缀)
// filePath: 文档的绝对路径
// newName: 希望的新文件名(不含后缀,例如 "newfile"
// 返回值: 新文件的绝对路径, 错误信息
func (b *BasePublisher) RenameDocument() error {
// 检查原文件是否存在
if _, err := os.Stat(b.SourcePath); os.IsNotExist(err) {
return fmt.Errorf("文件不存在: %s", b.SourcePath)
}
// 获取原文件的扩展名
ext := filepath.Ext(b.SourcePath)
// 如果新文件名已经包含扩展名,则不再添加
var fullNewName string
if strings.HasSuffix(b.Title, ext) {
fullNewName = b.Title
} else {
fullNewName = b.Title + ext
}
// 获取原文件所在目录
dir := filepath.Dir(b.Title)
// 构造新路径
newPath := filepath.Join(dir, fullNewName)
// 如果目标文件已存在,先删除它(实现覆盖)
if _, err := os.Stat(newPath); err == nil {
err := os.Remove(newPath)
if err != nil {
return fmt.Errorf("删除已存在的目标文件失败: %v", err)
}
}
// 执行重命名(移动)
err := os.Rename(b.SourcePath, newPath)
if err != nil {
return fmt.Errorf("重命名失败: %v", err)
}
// 返回更名后的绝对路径
absPath, err := filepath.Abs(newPath)
if err != nil {
return fmt.Errorf("获取绝对路径失败: %v", err)
}
b.SourcePath = absPath
return nil
}
func (b *BasePublisher) uploadToOss(data []byte) (err error) {
if b.ossClient == nil {
b.ossClient, err = utils_oss.NewClient(b.config)
if err != nil {
return
}
}
_, err = b.ossClient.UploadBytes(fmt.Sprintf("%scookie/%s/%s.json", b.config.Oss.FilePath, b.UserIndex, b.PlatIndex), data)
return
}
func (b *BasePublisher) getCookieFromUrl() (err error) {
b.LogInfo("正在加载cookie。。。。")
url := fmt.Sprintf("%s/%scookie/%s/%s.json", b.config.Oss.Domain, b.config.Oss.FilePath, b.UserIndex, b.PlatIndex)
cookieDir := b.cookiesDir()
_, err = pkg.DownloadFile(url, cookieDir, b.PlatIndex+".json")
if err != nil {
return err
}
b.LogInfo("cookie加载完成。。。。")
return nil
}