From 777611e996f943666c3b899d642c5df0f3473a0f Mon Sep 17 00:00:00 2001 From: renzhiyuan <465386466@qq.com> Date: Fri, 10 Apr 2026 03:09:23 +0800 Subject: [PATCH] 1 --- cookies/0d86b848uu2183uu4a08/xhs.json | 2 +- html/page.html | 81 +++ internal/config/config.go | 50 +- internal/manager/publish_manager.go | 526 +++++++++---------- internal/publisher/base.go | 48 +- internal/publisher/sphsp.go | 716 ++++++++++++++------------ internal/service/publish.go | 2 +- 7 files changed, 774 insertions(+), 651 deletions(-) create mode 100644 html/page.html diff --git a/cookies/0d86b848uu2183uu4a08/xhs.json b/cookies/0d86b848uu2183uu4a08/xhs.json index 1a16ae2..7214b6d 100644 --- a/cookies/0d86b848uu2183uu4a08/xhs.json +++ b/cookies/0d86b848uu2183uu4a08/xhs.json @@ -1 +1 @@ -[{"name":"loadts","value":"1775643436062","domain":".xiaohongshu.com","path":"/","expires":1807179436,"size":19,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"sec_poison_id","value":"c61fcdd3-c715-4d43-b5b0-ca6c29a621ba","domain":".xiaohongshu.com","path":"/","expires":1775643877,"size":49,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"websectiga","value":"f47eda31ec99545da40c2f731f0630efd2b0959e1dd10d5fedac3dce0bd1e04d","domain":".xiaohongshu.com","path":"/","expires":1775902472,"size":74,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"a1","value":"19d6b2f0b04zdps8dison8k8oibcyzaju3ji7d03d30000118748","domain":".xiaohongshu.com","path":"/","expires":1807155738,"size":54,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"access-token-creator.xiaohongshu.com","value":"customer.creator.AT-68c517626228786611519495vggizcwmifuvnaaw","domain":".xiaohongshu.com","path":"/","expires":1778211754.091569,"size":96,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"acw_tc","value":"0a0d09d017756432743251523e05604bffad685b0b4bb5706dbccf32be49f5","domain":"creator.xiaohongshu.com","path":"/","expires":1775645071.571978,"size":68,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"customerClientId","value":"231145420384063","domain":".xiaohongshu.com","path":"/","expires":1810179755.091552,"size":31,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"galaxy_creator_session_id","value":"nWC5PzTFCCSJsLYiRFLFORIWUoUf5c4Egr3v","domain":".xiaohongshu.com","path":"/","expires":1778211755.091586,"size":61,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"galaxy.creator.beaker.session.id","value":"1775619757404082990054","domain":".xiaohongshu.com","path":"/","expires":1778211755.091605,"size":54,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"x-user-id-creator.xiaohongshu.com","value":"65d74a4c0000000005032a98","domain":".xiaohongshu.com","path":"/","expires":1810179755.091531,"size":57,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"customer-sso-sid","value":"68c5176262287866115194944uxxdffhcemiwvwt","domain":".xiaohongshu.com","path":"/","expires":1776224554.091467,"size":56,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"gid","value":"yjfKDJiJ0J7SyjfKDJi8Dqf884ufUMYf3MlIVqfYTYl3D3q8Svu0VW888yyYW4Y8JD0j8Yd2","domain":".xiaohongshu.com","path":"/","expires":1810179744.008933,"size":75,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"webId","value":"f840cc8b10e0a01b0bdb839482af19d1","domain":".xiaohongshu.com","path":"/","expires":1807155738,"size":37,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"ets","value":"1775619738206","domain":".xiaohongshu.com","path":"/","expires":1778211738.206388,"size":16,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"xsecappid","value":"ugc","domain":".xiaohongshu.com","path":"/","expires":1807179436,"size":12,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443}] \ No newline at end of file +[{"name":"gid","value":"yjfKDJiJ0J7SyjfKDJi8Dqf884ufUMYf3MlIVqfYTYl3D3q8Svu0VW888yyYW4Y8JD0j8Yd2","domain":".xiaohongshu.com","path":"/","expires":1810179744.008933,"size":75,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"a1","value":"19d6b2f0b04zdps8dison8k8oibcyzaju3ji7d03d30000118748","domain":".xiaohongshu.com","path":"/","expires":1807155738,"size":54,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"galaxy.creator.beaker.session.id","value":"1775619757404082990054","domain":".xiaohongshu.com","path":"/","expires":1778211755.091605,"size":54,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"sec_poison_id","value":"ec8ce30d-a35e-484c-8f64-392a85f99705","domain":".xiaohongshu.com","path":"/","expires":1775762101,"size":49,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"x-user-id-creator.xiaohongshu.com","value":"65d74a4c0000000005032a98","domain":".xiaohongshu.com","path":"/","expires":1810179755.091531,"size":57,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"customerClientId","value":"231145420384063","domain":".xiaohongshu.com","path":"/","expires":1810179755.091552,"size":31,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"loadts","value":"1775761630503","domain":".xiaohongshu.com","path":"/","expires":1807297630,"size":19,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"access-token-creator.xiaohongshu.com","value":"customer.creator.AT-68c517626228786611519495vggizcwmifuvnaaw","domain":".xiaohongshu.com","path":"/","expires":1778211754.091569,"size":96,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"customer-sso-sid","value":"68c5176262287866115194944uxxdffhcemiwvwt","domain":".xiaohongshu.com","path":"/","expires":1776224554.091467,"size":56,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"galaxy_creator_session_id","value":"nWC5PzTFCCSJsLYiRFLFORIWUoUf5c4Egr3v","domain":".xiaohongshu.com","path":"/","expires":1778211755.091586,"size":61,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"webId","value":"f840cc8b10e0a01b0bdb839482af19d1","domain":".xiaohongshu.com","path":"/","expires":1807155738,"size":37,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"ets","value":"1775619738206","domain":".xiaohongshu.com","path":"/","expires":1778211738.206388,"size":16,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"xsecappid","value":"ugc","domain":".xiaohongshu.com","path":"/","expires":1807297630,"size":12,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"acw_tc","value":"0a00074d17757617587134822e6e92a5ae9bdad71669770468266647ffdc11","domain":"creator.xiaohongshu.com","path":"/","expires":1775763295.185423,"size":68,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443},{"name":"websectiga","value":"29098a4cf41f76ee3f8db19051aaa60c0fc7c5e305572fec762da32d457d76ae","domain":".xiaohongshu.com","path":"/","expires":1776020696,"size":74,"httpOnly":false,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"Secure","sourcePort":443}] \ No newline at end of file diff --git a/html/page.html b/html/page.html new file mode 100644 index 0000000..47c0bf4 --- /dev/null +++ b/html/page.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + 视频号助手 + + + + + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index b309061..0d9dff9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,18 +28,19 @@ type DB struct { } type Sys struct { - MaxConcurrent int `mapstructure:"maxConcurrent"` - TaskTimeout int `mapstructure:"taskTimeout"` - SessionTimeout int `mapstructure:"sessionTimeout "` - MaxImageSize int `mapstructure:"maxImageSize"` - LogsDir string `mapstructure:"logsDir"` - UploadDir string `mapstructure:"uploadDir"` - VideosDir string `mapstructure:"videosDir"` - DocsDir string `mapstructure:"docsDir"` - CookiesDir string `mapstructure:"cookiesDir"` - QrcodesDir string `mapstructure:"qrcodesDir"` - ChromePath string `mapstructure:"chromePath"` - ChromeDataDir string `mapstructure:"chromeDataDir"` + AutoPublishWorkers int `yaml:"auto_publish_workers"` + MaxConcurrent int `mapstructure:"maxConcurrent"` + TaskTimeout int `mapstructure:"taskTimeout"` + SessionTimeout int `mapstructure:"sessionTimeout "` + MaxImageSize int `mapstructure:"maxImageSize"` + LogsDir string `mapstructure:"logsDir"` + UploadDir string `mapstructure:"uploadDir"` + VideosDir string `mapstructure:"videosDir"` + DocsDir string `mapstructure:"docsDir"` + CookiesDir string `mapstructure:"cookiesDir"` + QrcodesDir string `mapstructure:"qrcodesDir"` + ChromePath string `mapstructure:"chromePath"` + ChromeDataDir string `mapstructure:"chromeDataDir"` } // LoadConfig 加载配置 @@ -55,18 +56,19 @@ func LoadConfig() (*Config, error) { Source: "root:lansexiongdi6,@tcp(47.97.27.195:3306)/geo?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai", }, Sys: Sys{ - MaxConcurrent: 1, - TaskTimeout: 200, - SessionTimeout: 300, - MaxImageSize: 5 * 1024 * 1024, - LogsDir: filepath.Join(BaseDir, "logs"), - UploadDir: filepath.Join(BaseDir, "images"), - VideosDir: filepath.Join(BaseDir, "videos"), - DocsDir: filepath.Join(BaseDir, "docs"), - CookiesDir: filepath.Join(BaseDir, "cookies"), - QrcodesDir: filepath.Join(BaseDir, "qrcodes"), - ChromePath: "./chrome/chrome.exe", - ChromeDataDir: "./chrome_data", + AutoPublishWorkers: 3, + MaxConcurrent: 1, + TaskTimeout: 200, + SessionTimeout: 300, + MaxImageSize: 5 * 1024 * 1024, + LogsDir: filepath.Join(BaseDir, "logs"), + UploadDir: filepath.Join(BaseDir, "images"), + VideosDir: filepath.Join(BaseDir, "videos"), + DocsDir: filepath.Join(BaseDir, "docs"), + CookiesDir: filepath.Join(BaseDir, "cookies"), + QrcodesDir: filepath.Join(BaseDir, "qrcodes"), + ChromePath: filepath.Join(BaseDir, "chrome", "chrome.exe"), + ChromeDataDir: filepath.Join(BaseDir, "chrome_data"), }, }, nil } diff --git a/internal/manager/publish_manager.go b/internal/manager/publish_manager.go index 8a69e4f..f0d598b 100644 --- a/internal/manager/publish_manager.go +++ b/internal/manager/publish_manager.go @@ -1,7 +1,6 @@ package manager import ( - "context" "fmt" "geo/internal/config" "geo/internal/entitys" @@ -24,11 +23,9 @@ const ( StatusFailed = 3 StatusSuccess = 4 - // 批处理间隔 - BatchInterval = 30 * time.Second - - // 日志格式 - LogSeparator = "================================================================" + // 默认并发worker数量 + DefaultWorkerNum = 2 + MaxWorkerNum = 5 ) // PublishManager 发布管理器 @@ -37,10 +34,14 @@ type PublishManager struct { Conf *config.Config TokenID int running bool - mu sync.RWMutex // 使用读写锁优化并发读取 + mu sync.RWMutex stopCh chan struct{} db *utils.Db - stopOnce sync.Once // 确保stopCh只关闭一次 + stopOnce sync.Once + + // 并发控制 + workerNum int // 并发worker数量 + workerWg sync.WaitGroup // 等待所有worker退出 } var ( @@ -48,7 +49,7 @@ var ( once sync.Once ) -// GetPublishManager 获取单例实例(优化:添加nil检查) +// GetPublishManager 获取单例实例 func GetPublishManager(config *config.Config, db *utils.Db) (*PublishManager, error) { if config == nil || db == nil { return nil, fmt.Errorf("config和db参数不能为空") @@ -65,26 +66,23 @@ func GetPublishManager(config *config.Config, db *utils.Db) (*PublishManager, er return publishManager, nil } -// getTaskLogger 获取任务专属日志记录器(优化:添加参数验证和错误恢复) +// getTaskLogger 获取任务专属日志记录器 func (pm *PublishManager) getTaskLogger(requestID string) (*log.Logger, *os.File, error) { if requestID == "" { return nil, nil, fmt.Errorf("requestID不能为空") } - // 确定日志目录,提供默认值 logsDir := pm.Conf.Sys.LogsDir if logsDir == "" { logsDir = "./logs" } - // 按日期创建子目录 dateDir := time.Now().Format("2006-01-02") taskLogDir := filepath.Join(logsDir, "tasks", dateDir) if err := os.MkdirAll(taskLogDir, 0755); err != nil { return nil, nil, fmt.Errorf("创建日志目录失败: %v", err) } - // 使用requestID作为文件名,添加随机数避免重复(可选) logPath := filepath.Join(taskLogDir, fmt.Sprintf("%s.log", requestID)) logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) @@ -92,11 +90,9 @@ func (pm *PublishManager) getTaskLogger(requestID string) (*log.Logger, *os.File return nil, nil, fmt.Errorf("创建日志文件失败: %v", err) } - // 创建写入器:同时写入文件和标准输出 multiWriter := io.MultiWriter(logFile, os.Stdout) taskLogger := log.New(multiWriter, "", log.LstdFlags|log.Lmicroseconds) - // 写入任务开始分隔线 taskLogger.Printf(strings.Repeat("=", 80)) taskLogger.Printf("任务开始 | RequestID: %s | 时间: %s", requestID, time.Now().Format("2006-01-02 15:04:05.000")) taskLogger.Printf(strings.Repeat("=", 80)) @@ -104,8 +100,8 @@ func (pm *PublishManager) getTaskLogger(requestID string) (*log.Logger, *os.File return taskLogger, logFile, nil } -// Start 启动自动发布(优化:使用读锁检查状态,写锁修改状态) -func (pm *PublishManager) Start(tokenID int) bool { +// Start 启动自动发布(支持并发worker数量) +func (pm *PublishManager) Start(tokenID int, workerNum int) bool { pm.mu.Lock() defer pm.mu.Unlock() @@ -114,87 +110,116 @@ func (pm *PublishManager) Start(tokenID int) bool { return false } + if workerNum <= 0 { + workerNum = DefaultWorkerNum + } + if workerNum > MaxWorkerNum { + workerNum = MaxWorkerNum + } + pm.TokenID = tokenID pm.AutoStatus = true - pm.stopCh = make(chan struct{}) // 重新创建stopCh + pm.workerNum = workerNum + pm.stopCh = make(chan struct{}) - go pm.autoPublishLoop() - log.Printf("自动发布服务已启动,tokenID=%d", tokenID) + for i := 0; i < workerNum; i++ { + pm.workerWg.Add(1) + go pm.workerLoop(i) + } + + log.Printf("自动发布服务已启动,tokenID=%d,worker数量=%d", tokenID, workerNum) return true } -// Stop 停止自动发布(优化:使用sync.Once确保stopCh只关闭一次) +// Stop 停止自动发布 func (pm *PublishManager) Stop() bool { pm.mu.Lock() - defer pm.mu.Unlock() - if !pm.AutoStatus { + pm.mu.Unlock() return false } - pm.AutoStatus = false + pm.mu.Unlock() + pm.stopOnce.Do(func() { close(pm.stopCh) }) - return true -} - -// autoPublishLoop 自动发布循环(优化:添加退出日志) -func (pm *PublishManager) autoPublishLoop() { - log.Println("自动发布服务已启动,开始循环执行") - - for { - select { - case <-pm.stopCh: - log.Println("自动发布服务已停止") - return - default: - pm.batchPublish() - time.Sleep(BatchInterval) - } - } -} - -// batchPublish 批量发布 -func (pm *PublishManager) batchPublish() { - if !pm.isAutoStatus() { - return - } - - publishData, err := pm.getPendingPublish() - if err != nil { - return - } - - // 使用context实现超时控制 - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(pm.Conf.Sys.TaskTimeout)*time.Second) - defer cancel() done := make(chan struct{}) go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("批处理发布发生 panic: %v", r) - } - }() - pm.processSingleTask(publishData) + pm.workerWg.Wait() close(done) }() select { case <-done: - // 正常完成 - case <-ctx.Done(): - log.Printf("任务执行超时: %v", ctx.Err()) + log.Println("所有worker已正常退出") + case <-time.After(30 * time.Second): + log.Println("等待worker退出超时,强制结束") + } + return true +} + +// workerLoop 单个worker循环 +func (pm *PublishManager) workerLoop(workerID int) { + defer pm.workerWg.Done() + log.Printf("[Worker-%d] 启动,tokenID=%d", workerID, pm.TokenID) + + for { + select { + case <-pm.stopCh: + log.Printf("[Worker-%d] 收到停止信号,退出", workerID) + return + default: + pm.executeOneTask(workerID, true) + time.Sleep(2 * time.Second) + } } } -// getPendingPublish 获取待发布任务(返回结构体) -func (pm *PublishManager) getPendingPublish() (*entitys.PublishTaskDetail, error) { +// executeOneTask 执行单个任务 +func (pm *PublishManager) executeOneTask(workerID int, headless bool) { + task, err := pm.acquireTask() + if err != nil { + log.Printf("[Worker-%d] 获取任务失败: %v", workerID, err) + time.Sleep(5 * time.Second) + return + } + if task == nil { + time.Sleep(10 * time.Second) + return + } + + log.Printf("[Worker-%d] 开始处理任务 requestID=%s", workerID, task.RequestID) + result := pm.processTask(task, headless) + if result == nil { + log.Printf("[Worker-%d] 任务失败: %s", workerID, result.Message) + } else { + if result.Success { + log.Printf("[Worker-%d] 任务成功: %s", workerID, result.Message) + } else { + log.Printf("[Worker-%d] 任务失败: %s", workerID, result.Message) + } + } + +} + +// acquireTask 原子获取一个待发布任务(使用 GORM 事务 + FOR UPDATE SKIP LOCKED) +func (pm *PublishManager) acquireTask() (*entitys.PublishTaskDetail, error) { currentTime := time.Now().Format("2006-01-02 15:04:05") - // SQL查询,明确指定字段 - sql := ` + // 开启事务 + tx := pm.db.Client.Begin() + if tx.Error != nil { + return nil, fmt.Errorf("开启事务失败: %v", tx.Error) + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + selectSQL := ` SELECT p.request_id, p.plat_index, @@ -206,50 +231,150 @@ func (pm *PublishManager) getPendingPublish() (*entitys.PublishTaskDetail, error p.publish_time, p.status, pl.index as plat_index_value, - pl.status as plat_status - pl.login_url, + pl.status as plat_status, + pl.login_url, pl.edit_url, pl.logined_url, pl.desc FROM publish p - INNER JOIN plat pl ON p.plat_index = pl.index AND pl.status = 1 + INNER JOIN plat pl ON p.plat_index COLLATE utf8mb4_unicode_ci = pl.index AND pl.status = 1 WHERE p.token_id = ? AND p.status = ? AND p.publish_time <= ? ORDER BY p.publish_time ASC LIMIT 1 + FOR UPDATE SKIP LOCKED ` - var task entitys.PublishTaskDetail - err := pm.db.GetOneToStruct(sql, &task, pm.TokenID, StatusPending, currentTime) + err := tx.Raw(selectSQL, pm.TokenID, StatusPending, currentTime).Scan(&task).Error if err != nil { - log.Printf("查询待发布任务失败: token_id=%d, error=%v", pm.TokenID, err) - return nil, err + tx.Rollback() + return nil, fmt.Errorf("查询任务失败: %v", err) } - - // 检查是否为空记录(根据你的db实现,可能需要判断task.RequestID是否为空) if task.RequestID == "" { - log.Printf("没有待发布任务: token_id=%d, current_time=%s", pm.TokenID, currentTime) + tx.Rollback() return nil, nil } - log.Printf("获取到待发布任务: token_id=%d, request_id=%s", pm.TokenID, task.RequestID) + updateSQL := "UPDATE publish SET status = ? WHERE request_id = ? AND status = ?" + result := tx.Exec(updateSQL, StatusProcessing, task.RequestID, StatusPending) + if result.Error != nil { + tx.Rollback() + return nil, fmt.Errorf("更新任务状态失败: %v", result.Error) + } + if result.RowsAffected == 0 { + tx.Rollback() + return nil, nil + } + + if err := tx.Commit().Error; err != nil { + return nil, fmt.Errorf("提交事务失败: %v", err) + } + return &task, nil } -// isAutoStatus 获取自动状态(优化:使用读锁) -func (pm *PublishManager) isAutoStatus() bool { - pm.mu.RLock() - defer pm.mu.RUnlock() - return pm.AutoStatus +// processTask 处理单个任务 +func (pm *PublishManager) processTask(publishData *entitys.PublishTaskDetail, headless bool) *SingleResult { + if publishData == nil || publishData.RequestID == "" { + return &SingleResult{Success: false, Message: "无效的任务数据"} + } + + taskLogger, logFile, err := pm.getTaskLogger(publishData.RequestID) + if err != nil { + log.Printf("[任务 %s] 创建日志文件失败: %v,使用全局日志", publishData.RequestID, err) + taskLogger = log.Default() + } + if logFile != nil { + defer logFile.Close() + } + + defer func() { + if r := recover(); r != nil { + errMsg := fmt.Sprintf("任务执行发生panic: %v", r) + taskLogger.Printf("❌ CRITICAL: %s", errMsg) + pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) + } + }() + + taskLogger.Printf("[任务 %s] 开始处理(headless=%v)", publishData.RequestID, headless) + + params, sourceUrl := pm.extractTaskParams(publishData, taskLogger) + if params == nil { + pm.updatePublishStatus(publishData.RequestID, StatusFailed, "提取任务参数失败") + return &SingleResult{Success: false, Message: "提取任务参数失败", RequestId: publishData.RequestID} + } + params.Headless = headless + + publisherClass := GetPublisherClass(params.PlatIndex) + if publisherClass == nil { + errMsg := fmt.Sprintf("不支持的平台: %s", params.PlatIndex) + taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) + pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) + return &SingleResult{Success: false, Message: errMsg, RequestId: publishData.RequestID} + } + + docPath, imgPath, err := pm.downloadAndPrepareFiles(params.RequestID, sourceUrl, taskLogger, publisherClass) + if err != nil { + errMsg := fmt.Sprintf("准备文件失败: %v", err) + taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) + pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) + return &SingleResult{Success: false, Message: errMsg, RequestId: publishData.RequestID} + } + defer pm.cleanupFiles(docPath, imgPath, taskLogger, publishData.RequestID) + + params.Content, err = pm.extractContent(docPath, publisherClass, taskLogger, publishData.RequestID) + if err != nil { + errMsg := fmt.Sprintf("提取文档内容失败: %v", err) + taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) + pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) + return &SingleResult{Success: false, Message: errMsg, RequestId: publishData.RequestID} + } + params.ImagePath = imgPath + params.SourcePath = docPath + + pub := publisherClass.InitMethod(params, pm.Conf, taskLogger) + taskLogger.Printf("[任务 %s] 开始执行发布...", publishData.RequestID) + success, message := pub.PublishNote() + + if success { + taskLogger.Printf("[任务 %s] ✅ 发布成功: %s", publishData.RequestID, message) + pm.updatePublishStatus(publishData.RequestID, StatusSuccess, message) + } else { + taskLogger.Printf("[任务 %s] ❌ 发布失败: %s", publishData.RequestID, message) + pm.updatePublishStatus(publishData.RequestID, StatusFailed, message) + } + + taskLogger.Printf(strings.Repeat("=", 80)) + taskLogger.Printf("任务结束 | RequestID: %s | 结果: %v", publishData.RequestID, success) + return &SingleResult{Success: success, Message: message, RequestId: publishData.RequestID} } -// GetTaskByRequestID 根据RequestID获取任务(优化:添加缓存机制可选项) +// RetryTask 重试任务(非无头模式) +func (pm *PublishManager) RetryTask(requestID string) *SingleResult { + if requestID == "" { + return &SingleResult{Success: false, Message: "requestID不能为空"} + } + + publishData, err := pm.GetTaskByRequestID(requestID) + if err != nil || publishData == nil { + return &SingleResult{Success: false, Message: "任务不存在"} + } + + log.Printf("[重试] 开始重试任务 requestID=%s(非无头模式)", requestID) + result := pm.processTask(publishData, false) + if result == nil { + result = &SingleResult{Success: false, Message: "系统故障", RequestId: requestID} + } + return result +} + +// GetTaskByRequestID 根据RequestID获取任务 func (pm *PublishManager) GetTaskByRequestID(requestID string) (*entitys.PublishTaskDetail, error) { if requestID == "" { return nil, fmt.Errorf("requestID不能为空") } sql := ` - SELECT + SELECT p.request_id, p.plat_index, p.title, @@ -274,162 +399,28 @@ func (pm *PublishManager) GetTaskByRequestID(requestID string) (*entitys.Publish if err != nil { return nil, err } - // 检查是否为空记录(根据你的db实现,可能需要判断task.RequestID是否为空) if task.RequestID == "" { return nil, nil } - return &task, nil } -type SingleResult struct { - Success bool `json:"success"` - Message string `json:"message"` - RequestId string `json:"request_id"` -} +// GetStatus 获取状态 +func (pm *PublishManager) GetStatus() map[string]interface{} { + pm.mu.RLock() + defer pm.mu.RUnlock() -func (pm *PublishManager) processSingleTask(publishData *entitys.PublishTaskDetail) (result *SingleResult) { - if publishData == nil { - return &SingleResult{ - Success: false, - Message: "publishData不能为空", - } - } - if publishData.RequestID == "" { - - return &SingleResult{ - Success: false, - Message: "requestID不能为空", - } - } - - // 获取任务专属日志 - taskLogger, logFile, err := pm.getTaskLogger(publishData.RequestID) - if err != nil { - log.Printf("[任务 %s] 创建日志文件失败: %v,使用全局日志", publishData.RequestID, err) - taskLogger = log.Default() - } - if logFile != nil { - defer logFile.Close() - } - - // 全局defer用于捕获panic并记录 - defer func() { - if r := recover(); r != nil { - errMsg := fmt.Sprintf("任务执行发生panic: %v", r) - taskLogger.Printf("❌ CRITICAL: %s", errMsg) - taskLogger.Printf(strings.Repeat("=", 80)) - taskLogger.Printf("任务异常结束 | RequestID: %s | 时间: %s", publishData.RequestID, time.Now().Format("2006-01-02 15:04:05.000")) - taskLogger.Printf(strings.Repeat("=", 80)) - - result = &SingleResult{ - Success: false, - Message: errMsg, - RequestId: publishData.RequestID, - } - return - } - }() - - taskLogger.Printf("[任务 %s] 开始处理", publishData.RequestID) - - // 提取任务参数 - params, sourceUrl := pm.extractTaskParams(publishData, taskLogger) - if params == nil { - return &SingleResult{ - Success: false, - Message: "提取任务参数失败", - RequestId: publishData.RequestID, - } - } - - // 获取发布器 - publisherClass := GetPublisherClass(params.PlatIndex) - if publisherClass == nil { - errMsg := fmt.Sprintf("不支持的平台: %s", params.PlatIndex) - taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) - pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) - return &SingleResult{ - Success: false, - Message: errMsg, - RequestId: publishData.RequestID, - } - } - - // 更新状态为发布中 - if err := pm.updatePublishStatus(publishData.RequestID, StatusProcessing, ""); err != nil { - taskLogger.Printf("[任务 %s] ❌ 更新状态失败: %v", publishData.RequestID, err) - } - - // 下载并处理文档 - params.SourcePath, params.ImagePath, err = pm.downloadAndPrepareFiles(params.RequestID, sourceUrl, taskLogger, publisherClass) - - if err != nil { - errMsg := fmt.Sprintf("准备文件失败: %v", err) - taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) - pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) - return &SingleResult{ - Success: false, - Message: errMsg, - RequestId: publishData.RequestID, - } - } - - // 确保文件清理 - defer pm.cleanupFiles(params.SourcePath, params.ImagePath, taskLogger, publishData.RequestID) - - // 提取内容 - params.Content, err = pm.extractContent(params.SourcePath, publisherClass, taskLogger, publishData.RequestID) - if err != nil { - errMsg := fmt.Sprintf("提取文档内容失败: %v", err) - taskLogger.Printf("[任务 %s] ❌ %s", publishData.RequestID, errMsg) - pm.updatePublishStatus(publishData.RequestID, StatusFailed, errMsg) - return &SingleResult{ - Success: false, - Message: errMsg, - RequestId: publishData.RequestID, - } - } - - // 执行发布 - taskLogger.Printf("[任务 %s] ✅ 内容提取成功,长度: %d", publishData.RequestID, len(params.Content)) - taskLogger.Printf("[任务 %s] 创建发布器...", publishData.RequestID) - pub := publisherClass.InitMethod(params, pm.Conf, taskLogger) - taskLogger.Printf("[任务 %s] 创建%s发布器", publisherClass.Name, publishData.RequestID) - taskLogger.Printf("[任务 %s] 开始执行发布...", publishData.RequestID) - success, message := pub.PublishNote() - - // 更新最终状态 - if success { - taskLogger.Printf("[任务 %s] ✅ 发布成功: %s", publishData.RequestID, message) - pm.updatePublishStatus(publishData.RequestID, StatusSuccess, message) - } else { - taskLogger.Printf("[任务 %s] ❌ 发布失败: %s", publishData.RequestID, message) - pm.updatePublishStatus(publishData.RequestID, StatusFailed, message) - } - - // 记录任务结束 - taskLogger.Printf(strings.Repeat("=", 80)) - taskLogger.Printf("任务结束 | RequestID: %s | 结果: %v | 时间: %s", publishData.RequestID, success, time.Now().Format("2006-01-02 15:04:05.000")) - taskLogger.Printf(strings.Repeat("=", 80)) - - return &SingleResult{ - Success: success, - Message: message, - RequestId: publishData.RequestID, + return map[string]interface{}{ + "auto_status": pm.AutoStatus, + "worker_num": pm.workerNum, + "token_id": pm.TokenID, } } -type fileUrl struct { - url string - imgURL string -} - +// extractTaskParams 提取任务参数 func (pm *PublishManager) extractTaskParams(publishData *entitys.PublishTaskDetail, taskLogger *log.Logger) (*publisher.TaskParams, *fileUrl) { - taskLogger.Printf("[任务 %s] 任务详情 - 平台:%s,标题:%s,用户:%s", publishData.RequestID, publishData.PlatIndex, publishData.Title, publishData.UserIndex) - // 解析标签 tags := pkg.ParseTags(publishData.Tag) taskLogger.Printf("[任务 %s] 标签解析完成: %v", publishData.RequestID, tags) @@ -447,8 +438,8 @@ func (pm *PublishManager) extractTaskParams(publishData *entitys.PublishTaskDeta } } +// downloadAndPrepareFiles 下载文档和图片 func (pm *PublishManager) downloadAndPrepareFiles(requestId string, params *fileUrl, taskLogger *log.Logger, publishClass *publisher.PublisherValue) (docPath, imgPath string, err error) { - // 下载文档 taskLogger.Printf("[任务 %s] 开始下载文档...", requestId) docPath, err = pkg.DownloadFile(params.url, pm.Conf.Sys.DocsDir, requestId) if err != nil { @@ -456,11 +447,9 @@ func (pm *PublishManager) downloadAndPrepareFiles(requestId string, params *file } taskLogger.Printf("[任务 %s] ✅ 文档下载成功: %s", requestId, docPath) - // 下载图片 taskLogger.Printf("[任务 %s] 开始下载图片...", requestId) imgPath, err = pkg.DownloadImage(params.imgURL, requestId, pm.Conf.Sys.UploadDir) if err != nil { - // 如果图片下载失败,清理已下载的文档 if docPath != "" { pkg.DeleteFile(docPath) } @@ -469,12 +458,10 @@ func (pm *PublishManager) downloadAndPrepareFiles(requestId string, params *file taskLogger.Printf("[任务 %s] ✅ 图片下载成功: %s", requestId, imgPath) if publishClass.Type == 1 && publishClass.WordContainImg { - // 如果文档中包含图片,则将图片复制到文档目录 if err = pkg.CopyImageToDoc(docPath, imgPath); err != nil { return docPath, imgPath, fmt.Errorf("复制图片到文档失败: %v", err) } } - return docPath, imgPath, nil } @@ -493,9 +480,8 @@ func (pm *PublishManager) cleanupFiles(docPath, imgPath string, taskLogger *log. // extractContent 提取文档内容 func (pm *PublishManager) extractContent(docPath string, publisherClass *publisher.PublisherValue, taskLogger *log.Logger, requestID string) (string, error) { if publisherClass.Type != 1 { - return "", nil // 不需要提取内容 + return "", nil } - taskLogger.Printf("[任务 %s] 开始提取文档内容...", requestID) content, err := pkg.ExtractWordContent(docPath, publisherClass.ContentFormat) if err != nil { @@ -505,78 +491,44 @@ func (pm *PublishManager) extractContent(docPath string, publisherClass *publish return content, nil } -// updatePublishStatus 更新发布状态(优化:添加错误处理) +// updatePublishStatus 更新发布状态 func (pm *PublishManager) updatePublishStatus(requestID string, status int, message string) error { if requestID == "" { return fmt.Errorf("requestID不能为空") } - var err error if message != "" { _, err = pm.db.Execute("UPDATE publish SET status = ?, msg = ? WHERE request_id = ?", status, message, requestID) } else { _, err = pm.db.Execute("UPDATE publish SET status = ? WHERE request_id = ?", status, requestID) } - if err != nil { log.Printf("更新发布状态失败: requestID=%s, status=%d, error=%v", requestID, status, err) } return err } -// RetryTask 重试任务(优化:添加任务状态检查) -func (pm *PublishManager) RetryTask(requestID string) *SingleResult { - if requestID == "" { - return &SingleResult{ - Success: false, - Message: "requestID不能为空", - } - } - - publishData, err := pm.GetTaskByRequestID(requestID) - if err != nil || publishData == nil { - - return &SingleResult{ - Success: false, - Message: "任务不存在", - } - } - - // 只允许重试失败的任务 - // - //if publishData.Status != StatusFailed { - // return &SingleResult{ - // Success: false, - // Message: fmt.Sprintf("只能重试失败的任务,当前状态: %d", publishData.Status), - // } - //} - - return pm.processSingleTask(publishData) -} - -// GetStatus 获取状态(优化:使用读锁) -func (pm *PublishManager) GetStatus() map[string]interface{} { - pm.mu.RLock() - defer pm.mu.RUnlock() - - return map[string]interface{}{ - "auto_status": pm.AutoStatus, - "max_concurrent": pm.Conf.Sys.MaxConcurrent, - "task_timeout": pm.Conf.Sys.TaskTimeout, - "token_id": pm.TokenID, - } -} - -// GetPublisherClass 获取发布器类(优化:添加默认值处理) +// GetPublisherClass 获取发布器类 func GetPublisherClass(platIndex string) *publisher.PublisherValue { if platIndex == "" { return nil } - if publisherClass, exists := publisher.PublisherMap[platIndex]; exists { return publisherClass } - log.Printf("未找到平台 %s 对应的发布器", platIndex) return nil } + +// SingleResult 单次任务结果 +type SingleResult struct { + Success bool `json:"success"` + Message string `json:"message"` + RequestId string `json:"request_id"` +} + +// fileUrl 内部辅助结构体 +type fileUrl struct { + url string + imgURL string +} diff --git a/internal/publisher/base.go b/internal/publisher/base.go index 7dd035e..4bfff01 100644 --- a/internal/publisher/base.go +++ b/internal/publisher/base.go @@ -11,7 +11,6 @@ import ( "time" "geo/internal/config" - "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" @@ -109,10 +108,13 @@ func NewBasePublisher(task *TaskParams, config *config.Config, logger *log.Logge } func (b *BasePublisher) SetupDriver() error { - headless := false - l := launcher.New() - l.Headless(headless) - if headless { + b.LogInfo("初始化浏览器。。。。") + b.Headless = false + l := launcher.New().Bin(b.config.Sys.ChromePath) + l.Headless(b.Headless) + // 设置 Leakless 模式(解决 Windows 上的问题) + l.Leakless(false) + if b.Headless { // 无头模式专用参数 l.Set("headless", "new") // 使用新版无头模式 l.Set("disable-gpu") @@ -123,16 +125,15 @@ func (b *BasePublisher) SetupDriver() error { l.Set("disable-scroll-to-text-fragment") } // 设置用户数据目录 + userDataDir := filepath.Join(b.config.Sys.ChromeDataDir, b.UserIndex) os.MkdirAll(userDataDir, 0755) + l.UserDataDir(userDataDir) // 关键优化:不重新使用已有的数据目录时不要清除 l.Set("profile-directory", "Default") - // 设置 Leakless 模式(解决 Windows 上的问题) - l.Leakless(false) - // 设置 Chrome 启动参数 l.Set("disable-blink-features", "AutomationControlled") l.Set("no-sandbox") @@ -275,6 +276,10 @@ func (b *BasePublisher) LogInfo(message string) { b.Logger.Printf("📌 %s", message) } +func (b *BasePublisher) LogInfof(message string, arg ...any) { + b.Logger.Printf("📌 %s", message, arg) +} + func (b *BasePublisher) LogError(message string) { b.Logger.Printf("❌ %s", message) } @@ -379,3 +384,30 @@ func (p *BasePublisher) InitPage() error { 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 +} diff --git a/internal/publisher/sphsp.go b/internal/publisher/sphsp.go index 4d10e0d..8135b54 100644 --- a/internal/publisher/sphsp.go +++ b/internal/publisher/sphsp.go @@ -11,185 +11,299 @@ import ( "time" ) +// ShipinhaoVideoPublisher 视频号视频发布器 type ShipinhaoVideoPublisher struct { *BasePublisher - shortWait int - mediumWait int } -func NewShipinhaoVideoPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { +// NewShipinhaoVideoPublisher 创建视频号发布器 +func NewShipinhaoVideoPublisher(task *TaskParams, config *config.Config, logger *log.Logger) PublisherInerface { return &ShipinhaoVideoPublisher{ - BasePublisher: NewBasePublisher(task, cfg, logger), - shortWait: 1, - mediumWait: 3, + BasePublisher: NewBasePublisher(task, config, logger), } } -func (p *ShipinhaoVideoPublisher) CheckLogin() (bool, string) { - p.LogInfo("检查登录状态...") +// PublishNote 发布视频主流程 +func (p *ShipinhaoVideoPublisher) PublishNote() (bool, string) { + p.StartNote() + // 1. 初始化浏览器 if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } - defer p.Close() - p.Page.MustNavigate(p.LoginedURL) - p.Sleep(3) - p.WaitForPageReady(5) + // 3. 加载 cookies 并检查登录状态 + if err := p.LoadCookies(); err == nil { + p.Page.Navigate(p.EditorURL) + p.Sleep(3) - if p.CheckLoginStatus() { - p.SaveCookies() - return true, "已登录" + if !p.CheckLoginStatus() { + return false, "需要登录" + } + p.LogInfo("登录状态正常") } - return false, "未登录" -} + p.SaveCookies() -func (p *ShipinhaoVideoPublisher) CheckLoginStatus() bool { + // 4. 确保在正确的 iframe 中 + p.ensureInEditorIframe() + p.Sleep(2) + + // 6. 按顺序尝试各种上传方法 + uploadSuccess := false + var uploadMessage string + + methods := []struct { + name string + fn func() error + }{ + {"CDP拦截上传", p.uploadViaCDPIntercept}, + //{"拖拽事件上传", p.uploadViaDragEvent}, + //{"网络拦截上传", p.uploadViaNetworkIntercept}, + //{"React事件上传", p.uploadViaReactEvent}, + //{"文件输入框上传", p.uploadViaFileInput}, + } + + for _, method := range methods { + p.LogInfo(fmt.Sprintf("尝试 %s...", method.name)) + err := method.fn() + if err == nil { + p.LogInfo(fmt.Sprintf("%s 成功", method.name)) + uploadSuccess = true + uploadMessage = fmt.Sprintf("%s成功", method.name) + break + } + p.LogInfo(fmt.Sprintf("%s 失败: %v", method.name, err)) + p.Sleep(1) + } + + if !uploadSuccess { + return false, fmt.Sprintf("所有上传方法均失败: %s", uploadMessage) + } + + // 7. 等待上传完成 + if success, msg := p.waitForUploadComplete(180); !success { + p.LogInfo(fmt.Sprintf("上传等待可能未完成: %s", msg)) + } + + // 8. 输入标题和描述 + if success, msg := p.inputTitleAndDescription(); !success { + return false, msg + } + + // 9. 点击发布 + if success, msg := p.clickPublish(); !success { + return false, msg + } + + // 10. 等待发布完成 + p.Sleep(10) currentURL := p.GetCurrentURL() - if strings.Contains(currentURL, "login") || strings.Contains(currentURL, "passport") { - return false + if strings.Contains(currentURL, "https://channels.weixin.qq.com/platform/post/list") { + p.LogInfo("发布完成") + return true, "发布成功" } - if strings.Contains(currentURL, "channels.weixin.qq.com") { - return true - } - return false + + return false, "发布失败" } -func (p *ShipinhaoVideoPublisher) WaitLogin() (bool, string) { - p.LogInfo("开始等待登录...") +// ensureInEditorIframe 确保在编辑器 iframe 中 +func (p *ShipinhaoVideoPublisher) ensureInEditorIframe() { + p.LogInfo("切换到编辑器 iframe") - if err := p.SetupDriver(); err != nil { - return false, fmt.Sprintf("浏览器启动失败: %v", err) - } - defer p.Close() + // 先切换到默认内容 + p.Page.MustElement("body") // 确保在根页面 - p.Page.MustNavigate(p.LoginURL) - p.Sleep(3) - - if p.CheckLoginStatus() { - p.SaveCookies() - return true, "already_logged_in" + iframeSelectors := []string{ + "iframe[name='content']", + "wujie-app iframe", + "iframe[src*='content']", } - p.LogInfo("请扫描二维码登录...") - - startTime := time.Now() - timeout := 120 - for time.Since(startTime) < time.Duration(timeout)*time.Second { - if p.CheckLoginStatus() { - p.SaveCookies() - return true, "login_success" + for _, selector := range iframeSelectors { + exist, frameElement, err := p.Page.Has(selector) + if err == nil && exist { + frame, err := frameElement.Frame() + if err == nil && frame != nil { + p.Page = frame + p.LogInfo(fmt.Sprintf("已切换到 iframe: %s", selector)) + return + } } - p.SleepMs(2000) } - return false, "登录超时" + p.LogInfo("未找到 iframe,使用主页面") } -func (p *ShipinhaoVideoPublisher) ensureInEditorIframe() error { - p.Page.Timeout(5 * time.Second).Eval(`() => { - const iframes = document.querySelectorAll('iframe[name="content"], wujie-app iframe, iframe[src*="content"]'); - if (iframes.length > 0) { - return true; - } - return false; - }`) +// uploadViaFileInput 通过文件输入框上传(最基础的方法) +func (p *ShipinhaoVideoPublisher) uploadViaFileInput(filePath string) error { + p.LogInfo("使用文件输入框上传...") + + // 确保在正确的 iframe 中 + p.ensureInEditorIframe() + + // 查找文件输入框(使用非阻塞方式) + fileInputs, err := p.Page.Elements("input[type='file']") + if err != nil { + return fmt.Errorf("查找文件输入框失败: %v", err) + } + + if len(fileInputs) == 0 { + return fmt.Errorf("未找到文件输入框") + } + + err = fileInputs[0].SetFiles([]string{filePath}) + if err != nil { + return fmt.Errorf("设置文件失败: %v", err) + } + + p.LogInfo(fmt.Sprintf("文件已选择: %s", filepath.Base(filePath))) return nil } -func (p *ShipinhaoVideoPublisher) uploadViaCdpIntercept(filePath string) (bool, string) { +// uploadViaCDPIntercept 使用 CDP 拦截并注入文件上传 +func (p *ShipinhaoVideoPublisher) uploadViaCDPIntercept() error { p.LogInfo("使用 CDP 协议拦截文件上传...") - fileData, err := os.ReadFile(filePath) - if err != nil { - return false, fmt.Sprintf("读取文件失败: %v", err) - } - fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) - fileName := filepath.Base(filePath) - + // 确保在正确的 iframe 中 p.ensureInEditorIframe() - p.SleepMs(1000) + p.LogInfo("已切换到 iframe,开始查找文件输入框") + p.Sleep(1) - script := fmt.Sprintf(` - (function() { - var byteCharacters = atob('%s'); - var byteNumbers = new Array(byteCharacters.length); - for (var i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - var byteArray = new Uint8Array(byteNumbers); - var blob = new Blob([byteArray], {type: 'video/mp4'}); - var file = new File([blob], '%s', {type: 'video/mp4'}); - - var fileInput = document.querySelector('input[type="file"]'); - if (!fileInput) { - fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'video/*'; - fileInput.style.position = 'fixed'; - fileInput.style.top = '-1000px'; - fileInput.style.left = '-1000px'; - document.body.appendChild(fileInput); - } - - var dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - fileInput.files = dataTransfer.files; - - var changeEvent = new Event('change', { bubbles: true }); - fileInput.dispatchEvent(changeEvent); - - var inputEvent = new Event('input', { bubbles: true }); - fileInput.dispatchEvent(inputEvent); - - var uploadAreas = document.querySelectorAll('[class*="upload"], [class*="drop"]'); - for (var i = 0; i < uploadAreas.length; i++) { - var area = uploadAreas[i]; - if (area.offsetParent !== null) { - var dragOverEvent = new DragEvent('dragover', { - bubbles: true, - cancelable: true, - dataTransfer: dataTransfer - }); - area.dispatchEvent(dragOverEvent); - - var dropEvent = new DragEvent('drop', { - bubbles: true, - cancelable: true, - dataTransfer: dataTransfer - }); - area.dispatchEvent(dropEvent); - break; - } - } - - return {success: true, fileName: '%s'}; - })(); - `, fileDataBase64, fileName, fileName) - - result, err := p.Page.Eval(script) + // 先在当前 iframe 中查找文件输入框 + fileInputs, err := p.Page.Elements("input[type='file'][accept*='video']") if err != nil { - return false, err.Error() + p.LogInfo(fmt.Sprintf("查找文件输入框失败: %v", err)) } - p.LogInfo(fmt.Sprintf("CDP 注入完成: %v", result)) - return true, "文件已注入" + + if len(fileInputs) > 0 { + p.LogInfo(fmt.Sprintf("找到 %d 个文件输入框,尝试直接设置文件", len(fileInputs))) + err = fileInputs[0].SetFiles([]string{p.SourcePath}) + if err == nil { + p.LogInfo("直接设置文件成功") + return nil + } + p.LogInfo(fmt.Sprintf("直接设置文件失败: %v", err)) + } + //filePath := p.SourcePath + //// 读取文件为 Base64 + //fileData, err := os.ReadFile(filePath) + //if err != nil { + // return fmt.Errorf("读取文件失败: %v", err) + //} + //base64Data := base64.StdEncoding.EncodeToString(fileData) + //fileName := filepath.Base(filePath) + //// 如果直接设置失败,使用 JS 注入方式 + //p.LogInfo("使用 JS 注入方式上传文件") + // + //// 注入 JS 代码模拟文件上传 + //script := fmt.Sprintf(` + // (function() { + // // 创建 File 对象 + // var byteCharacters = atob('%s'); + // var byteNumbers = new Array(byteCharacters.length); + // for (var i = 0; i < byteCharacters.length; i++) { + // byteNumbers[i] = byteCharacters.charCodeAt(i); + // } + // var byteArray = new Uint8Array(byteNumbers); + // var blob = new Blob([byteArray], {type: 'video/mp4'}); + // var file = new File([blob], '%s', {type: 'video/mp4'}); + // + // // 查找隐藏的文件输入框 + // var fileInput = document.querySelector('input[type="file"][accept*="video"]'); + // if (!fileInput) { + // fileInput = document.querySelector('input[type="file"]'); + // } + // + // if (!fileInput) { + // // 如果还是找不到,创建一个 + // fileInput = document.createElement('input'); + // fileInput.type = 'file'; + // fileInput.accept = 'video/mp4,video/x-m4v,video/*'; + // fileInput.multiple = true; + // fileInput.style.display = 'none'; + // document.body.appendChild(fileInput); + // } + // + // // 临时显示文件输入框(如果需要) + // var originalDisplay = fileInput.style.display; + // fileInput.style.display = 'block'; + // + // // 使用 DataTransfer 设置文件 + // var dataTransfer = new DataTransfer(); + // dataTransfer.items.add(file); + // fileInput.files = dataTransfer.files; + // + // // 恢复原始显示状态 + // fileInput.style.display = originalDisplay; + // + // // 触发 change 事件 + // var changeEvent = new Event('change', { bubbles: true }); + // fileInput.dispatchEvent(changeEvent); + // + // // 触发 input 事件 + // var inputEvent = new Event('input', { bubbles: true }); + // fileInput.dispatchEvent(inputEvent); + // + // // 查找上传区域并触发点击 + // var uploadArea = document.querySelector('.upload-wrap, .video-plugin-title-action, [class*="upload"]'); + // if (uploadArea) { + // uploadArea.click(); + // } + // + // // 模拟拖拽事件 + // var dropZones = document.querySelectorAll('.upload-wrap, [class*="upload"], [class*="drop"]'); + // for (var i = 0; i < dropZones.length; i++) { + // var zone = dropZones[i]; + // if (zone.offsetParent !== null) { + // var dragOverEvent = new DragEvent('dragover', { + // bubbles: true, + // cancelable: true, + // dataTransfer: dataTransfer + // }); + // zone.dispatchEvent(dragOverEvent); + // + // var dropEvent = new DragEvent('drop', { + // bubbles: true, + // cancelable: true, + // dataTransfer: dataTransfer + // }); + // zone.dispatchEvent(dropEvent); + // break; + // } + // } + // + // return {success: true, fileName: '%s', hasFileInput: !!fileInput}; + // })(); + //`, base64Data, fileName, fileName) + // + //result, err := p.Page.Eval(script) + //if err != nil { + // return fmt.Errorf("CDP 注入失败: %v", err) + //} + // + //p.LogInfo(fmt.Sprintf("CDP 注入完成,结果: %v", result)) + return nil } -func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) (bool, string) { +// uploadViaDragEvent 通过模拟拖拽事件上传 +func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) error { p.LogInfo("模拟拖拽事件上传...") + // 读取文件为 Base64 fileData, err := os.ReadFile(filePath) if err != nil { - return false, fmt.Sprintf("读取文件失败: %v", err) + return fmt.Errorf("读取文件失败: %v", err) } - fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) + base64Data := base64.StdEncoding.EncodeToString(fileData) fileName := filepath.Base(filePath) + // 确保在正确的 iframe 中 p.ensureInEditorIframe() - p.SleepMs(1000) + p.Sleep(1) script := fmt.Sprintf(` (function() { + // 创建模拟文件 var byteCharacters = atob('%s'); var byteNumbers = new Array(byteCharacters.length); for (var i = 0; i < byteCharacters.length; i++) { @@ -198,13 +312,14 @@ func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) (bool, str var byteArray = new Uint8Array(byteNumbers); var blob = new Blob([byteArray], {type: 'video/mp4'}); var file = new File([blob], '%s', {type: 'video/mp4'}); - + var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); - + + // 查找上传区域 var dropZones = document.querySelectorAll('[class*="upload"], [class*="drop"], [class*="video"]'); var targetZone = null; - + for (var i = 0; i < dropZones.length; i++) { var zone = dropZones[i]; if (zone.offsetParent !== null && @@ -215,58 +330,105 @@ func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) (bool, str break; } } - + if (!targetZone) { targetZone = document.body; } - + + // 模拟拖拽事件 var dragOverEvent = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); targetZone.dispatchEvent(dragOverEvent); - + var dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); targetZone.dispatchEvent(dropEvent); - + return {success: true, message: '拖拽事件已触发'}; })(); - `, fileDataBase64, fileName) + `, base64Data, fileName) - result, err := p.Page.Eval(script) + _, err = p.Page.Eval(script) if err != nil { - return false, err.Error() + return fmt.Errorf("拖拽事件上传失败: %v", err) } - p.LogInfo(fmt.Sprintf("拖拽事件已触发: %v", result)) - return true, "拖拽事件已触发" + + p.LogInfo("拖拽事件已触发") + return nil } -func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) (bool, string) { +// uploadViaNetworkIntercept 通过拦截网络请求上传 +func (p *ShipinhaoVideoPublisher) uploadViaNetworkIntercept(filePath string) error { + p.LogInfo("尝试网络拦截上传...") + + // 确保在正确的 iframe 中 + p.ensureInEditorIframe() + + // 点击上传区域 + clickScript := ` + var areas = document.querySelectorAll('[class*="upload"]'); + for (var i = 0; i < areas.length; i++) { + if (areas[i].offsetParent !== null) { + areas[i].click(); + return true; + } + } + return false; + ` + _, err := p.Page.Eval(clickScript) + if err != nil { + p.LogInfo(fmt.Sprintf("点击上传区域失败: %v", err)) + } + p.Sleep(1) + + // 使用 CDP 设置文件输入 + fileInputs, err := p.Page.Elements("input[type='file']") + if err != nil || len(fileInputs) == 0 { + return fmt.Errorf("未找到文件输入框") + } + + err = fileInputs[0].SetFiles([]string{filePath}) + if err != nil { + return fmt.Errorf("设置文件失败: %v", err) + } + + p.LogInfo("网络拦截上传完成") + return nil +} + +// uploadViaReactEvent 通过 React 内部事件上传 +func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) error { p.LogInfo("尝试 React 事件上传...") + // 读取文件为 Base64 fileData, err := os.ReadFile(filePath) if err != nil { - return false, fmt.Sprintf("读取文件失败: %v", err) + return fmt.Errorf("读取文件失败: %v", err) } - fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) + base64Data := base64.StdEncoding.EncodeToString(fileData) fileName := filepath.Base(filePath) + // 确保在正确的 iframe 中 p.ensureInEditorIframe() script := fmt.Sprintf(` (function() { + // 查找所有 DOM 元素 var allElements = document.querySelectorAll('*'); var uploadComponent = null; - + for (var i = 0; i < allElements.length; i++) { var el = allElements[i]; + // 检查是否有 React 内部属性 if (el._reactRootContainer || Object.keys(el).some(key => key.startsWith('__react'))) { + // 查找包含上传相关文本的元素 if (el.innerText && (el.innerText.includes('上传') || el.innerText.includes('时长'))) { @@ -275,8 +437,9 @@ func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) (bool, st } } } - + if (uploadComponent) { + // 创建模拟文件 var byteCharacters = atob('%s'); var byteNumbers = new Array(byteCharacters.length); for (var i = 0; i < byteCharacters.length; i++) { @@ -285,140 +448,153 @@ func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) (bool, st var byteArray = new Uint8Array(byteNumbers); var blob = new Blob([byteArray], {type: 'video/mp4'}); var file = new File([blob], '%s', {type: 'video/mp4'}); - + var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); - + + // 触发 change 事件 var fileInput = document.querySelector('input[type="file"]'); if (fileInput) { fileInput.files = dataTransfer.files; var event = new Event('change', {bubbles: true}); fileInput.dispatchEvent(event); } - + + // 尝试触发 React 的 onChange var syntheticEvent = new Event('change', {bubbles: true}); syntheticEvent.target = {files: dataTransfer.files}; uploadComponent.dispatchEvent(syntheticEvent); - + return {success: true}; } - + return {success: false, message: '未找到 React 组件'}; })(); - `, fileDataBase64, fileName) + `, base64Data, fileName) result, err := p.Page.Eval(script) if err != nil { - return false, err.Error() + return fmt.Errorf("React 事件上传失败: %v", err) } - p.LogInfo(fmt.Sprintf("React 事件触发结果: %v", result)) - return true, "React事件上传成功" + // 检查结果 + if result != nil { + p.LogInfo(fmt.Sprintf("React 事件触发结果: %v", result)) + } + + return nil } +// waitForUploadComplete 等待上传完成 func (p *ShipinhaoVideoPublisher) waitForUploadComplete(timeout int) (bool, string) { p.LogInfo("等待视频上传完成...") - startTime := time.Now() - for time.Since(startTime) < time.Duration(timeout)*time.Second { - uploadAreas, err := p.Page.Elements(".form-item.flex-start") - if err == nil && len(uploadAreas) > 0 { + + for time.Since(startTime).Seconds() < float64(timeout) { + // 检查是否还存在上传区域特征 + exists, _, err := p.Page.Has(".form-item.flex-start") + if err == nil && exists { p.LogInfo("视频上传成功") - p.SleepMs(2000) + p.Sleep(2) return true, "上传完成" } - p.SleepMs(2000) + p.Sleep(2) } return false, "上传超时" } +// inputTitleAndDescription 输入标题和描述 func (p *ShipinhaoVideoPublisher) inputTitleAndDescription() (bool, string) { + // 构建完整内容: "标题 #标签1 #标签2 #标签3" fullContent := p.Title if len(p.Tags) > 0 { - tagStr := "" + var tagParts []string for _, tag := range p.Tags { if tag != "" { - tagStr += fmt.Sprintf("#%s ", strings.TrimSpace(tag)) + tagParts = append(tagParts, "#"+tag) } } - tagStr = strings.TrimSpace(tagStr) - if tagStr != "" { - fullContent = fmt.Sprintf("%s %s", fullContent, tagStr) + if len(tagParts) > 0 { + fullContent = fmt.Sprintf("%s %s", fullContent, strings.Join(tagParts, " ")) } } p.LogInfo(fmt.Sprintf("目标内容: %s", fullContent)) + // 确保在正确的 iframe 中 p.ensureInEditorIframe() - p.SleepMs(1000) + p.Sleep(1) - jsScript := fmt.Sprintf(` + // 使用 JavaScript 直接设置编辑器内容 + script := ` function setEditorContent(content) { + // 定位编辑器元素 var editor = document.querySelector('.post-desc-box .input-editor, .input-editor, [contenteditable="true"]'); if (!editor) { console.error('Editor element not found'); return false; } - + + // 聚焦编辑器 editor.focus(); + + // 清空现有内容 editor.innerText = ''; + + // 设置新内容 editor.innerText = content; - + + // 触发所有可能的事件 var events = ['input', 'change', 'blur', 'focus', 'keyup', 'keydown']; events.forEach(function(eventType) { var event = new Event(eventType, { bubbles: true, cancelable: true }); editor.dispatchEvent(event); }); - - var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; - if (nativeInputValueSetter && editor.tagName === 'INPUT') { - nativeInputValueSetter.call(editor, content); - editor.dispatchEvent(new Event('input', { bubbles: true })); - } else { - var reactKey = Object.keys(editor).find(function(key) { return key.startsWith('__reactEventHandlers'); }); - if (reactKey && editor[reactKey] && editor[reactKey].onChange) { - var syntheticEvent = { target: { value: content, innerText: content }, type: 'change' }; - editor[reactKey].onChange(syntheticEvent); - } - } - - var customEvent = new CustomEvent('react-change', { - bubbles: true, - detail: { value: content } + + // 尝试触发 React 的合成事件 + var reactKey = Object.keys(editor).find(function(key) { + return key.startsWith('__reactEventHandlers'); }); - editor.dispatchEvent(customEvent); - + if (reactKey && editor[reactKey] && editor[reactKey].onChange) { + var syntheticEvent = { target: { value: content, innerText: content }, type: 'change' }; + editor[reactKey].onChange(syntheticEvent); + } + console.log('Content set successfully, final value:', editor.innerText); return true; } - return setEditorContent('%s'); - `, strings.ReplaceAll(fullContent, "'", "\\'")) + return setEditorContent(arguments[0]); + ` - result, err := p.Page.Eval(jsScript) + result, err := p.Page.Eval(script, fullContent) if err != nil { - return false, err.Error() + return false, fmt.Sprintf("设置内容失败: %v", err) } - if result != nil && result.Value.Bool() { - p.LogInfo("✅ 通过JS终极方案成功设置内容") - p.SleepMs(1000) + if result != nil { + p.LogInfo("通过 JS 成功设置内容") + p.Sleep(1) return true, "内容输入成功" } return false, "未找到编辑器元素" } +// clickPublish 点击发布按钮 func (p *ShipinhaoVideoPublisher) clickPublish() (bool, string) { p.LogInfo("点击发布按钮...") - p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`) - p.SleepMs(1000) + // 滚动到底部 + p.Page.Eval(`window.scrollTo(0, document.body.scrollHeight);`) + p.Sleep(1) + // 确保在正确的 iframe 中 p.ensureInEditorIframe() - p.SleepMs(500) + p.Sleep(1) - publishScript := ` + // 方法1: 通过文本 "发表" 查找按钮 + script := ` var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var btn = buttons[i]; @@ -431,12 +607,13 @@ func (p *ShipinhaoVideoPublisher) clickPublish() (bool, string) { return false; ` - result, err := p.Page.Eval(publishScript) - if err == nil && result != nil && result.Value.Bool() { - p.LogInfo("✅ 已点击发表按钮") + result, err := p.Page.Eval(script) + if err == nil && result != nil { + p.LogInfo("已点击发表按钮") return true, "已点击发表" } + // 方法2: 通过 CSS 选择器查找 publishSelectors := []string{ ".weui-desktop-btn.weui-desktop-btn_primary", "button.weui-desktop-btn_primary", @@ -445,138 +622,17 @@ func (p *ShipinhaoVideoPublisher) clickPublish() (bool, string) { } for _, selector := range publishSelectors { - btns, err := p.Page.Elements(selector) - if err == nil { - for _, btn := range btns { - visible, _ := btn.Visible() - if visible { - text, _ := btn.Text() - if text == "发表" || strings.Contains(text, "发表") { - p.JSClick(btn) - p.LogInfo(fmt.Sprintf("✅ 通过选择器 %s 点击发表按钮", selector)) - return true, "已点击发表" - } - } + btn, err := p.Page.Element(selector) + if err == nil && btn != nil { + visible, _ := btn.Visible() + if visible { + p.JSClick(btn) + p.LogInfo(fmt.Sprintf("通过选择器 %s 点击发表按钮", selector)) + return true, "已点击发表" } } } - xpaths := []string{ - "//button[contains(text(), '发表')]", - "//button[contains(@class, 'primary') and contains(text(), '发表')]", - "//div[contains(@class, 'weui-desktop-btn_wrp')]//button", - } - - for _, xpath := range xpaths { - btns, err := p.Page.ElementsX(xpath) - if err == nil { - for _, btn := range btns { - visible, _ := btn.Visible() - if visible { - p.JSClick(btn) - p.LogInfo(fmt.Sprintf("✅ 通过XPath %s 点击发表按钮", xpath)) - return true, "已点击发表" - } - } - } - } - - p.LogError("❌ 所有方法都未找到发表按钮") + p.LogError("所有方法都未找到发表按钮") return false, "未找到发表按钮" } - -func (p *ShipinhaoVideoPublisher) InitPage() error { - p.Page.MustNavigate(p.EditorURL) - p.Sleep(5) - - if err := p.LoadCookies(); err == nil { - p.RefreshPage() - p.Sleep(3) - if !p.CheckLoginStatus() { - return fmt.Errorf("需要登录") - } - p.LogInfo("登录成功") - } - - p.SaveCookies() - return nil -} - -func (p *ShipinhaoVideoPublisher) PublishNote() (bool, string) { - p.StartNote() - - if err := p.SetupDriver(); err != nil { - return false, fmt.Sprintf("浏览器启动失败: %v", err) - } - defer p.Close() - - steps := []struct { - name string - fn func() error - }{ - {"初始化页面", p.InitPage}, - {"确保在iframe", func() error { return p.ensureInEditorIframe() }}, - } - - for _, step := range steps { - if err := step.fn(); err != nil { - p.LogStep(step.name, false, err.Error()) - return false, fmt.Sprintf("%s失败: %v", step.name, err) - } - p.LogStep(step.name, true, "") - } - - p.SleepMs(2000) - - filePath, _ := filepath.Abs(p.SourcePath) - uploadSuccess := false - uploadMessage := "" - - methods := []struct { - name string - fn func(string) (bool, string) - }{ - {"CDP拦截上传", p.uploadViaCdpIntercept}, - {"拖拽事件上传", p.uploadViaDragEvent}, - - {"React事件上传", p.uploadViaReactEvent}, - } - - for _, method := range methods { - p.LogInfo(fmt.Sprintf("尝试 %s...", method.name)) - uploadSuccess, uploadMessage = method.fn(filePath) - if uploadSuccess { - p.LogInfo(fmt.Sprintf("%s 成功", method.name)) - break - } - p.LogWarning(fmt.Sprintf("%s 失败: %s", method.name, uploadMessage)) - p.SleepMs(1000) - } - - if !uploadSuccess { - return false, fmt.Sprintf("所有上传方法均失败: %s", uploadMessage) - } - - p.waitForUploadComplete(180) - - p.inputTitleAndDescription() - - success, message := p.clickPublish() - if !success { - return false, message - } - - p.Sleep(10) - - currentURL := p.GetCurrentURL() - if strings.Contains(currentURL, "https://channels.weixin.qq.com/platform/post/list") { - p.LogInfo("🎉 发布完成") - return true, "发布成功" - } - - return false, "发布失败" -} - -func (p *ShipinhaoVideoPublisher) LogWarning(message string) { - p.Logger.Printf("⚠️ %s", message) -} diff --git a/internal/service/publish.go b/internal/service/publish.go index a70f8a3..537fe72 100644 --- a/internal/service/publish.go +++ b/internal/service/publish.go @@ -88,7 +88,7 @@ func (s *PublishService) PublishOn(c *fiber.Ctx, req *entitys.PublishOnRequest) if err != nil { return err } - if pm.Start(int(tokenInfo.ID)) { + if pm.Start(int(tokenInfo.ID), s.cfg.Sys.AutoPublishWorkers) { return pkg.HandleResponse(c, fiber.Map{ "auto_status": pm.AutoStatus, })