This commit is contained in:
parent
e2aad42e81
commit
777611e996
|
|
@ -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}]
|
||||
[{"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}]
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue