523 lines
13 KiB
Go
523 lines
13 KiB
Go
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
|
||
}
|