commit
This commit is contained in:
@@ -13,8 +13,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -332,7 +330,7 @@ func (s *SchedulerService) AutoPublishArticles() {
|
||||
len(articles), successCount, failCount, duration)
|
||||
}
|
||||
|
||||
// publishArticle 发布单篇文案
|
||||
// publishArticle 发布单篇文案(使用FastAPI服务)
|
||||
func (s *SchedulerService) publishArticle(article models.Article) error {
|
||||
// 1. 获取用户信息(发布用户)
|
||||
var user models.User
|
||||
@@ -347,9 +345,22 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查用户是否绑定了小红书
|
||||
if user.IsBoundXHS != 1 || user.XHSCookie == "" {
|
||||
return errors.New("用户未绑定小红书账号或Cookie已失效")
|
||||
// 2. 检查用户是否绑定了小红书并获取author记录
|
||||
if user.IsBoundXHS != 1 {
|
||||
return errors.New("用户未绑定小红书账号")
|
||||
}
|
||||
|
||||
// 查询对应的 author 记录获取Cookie
|
||||
var author models.Author
|
||||
if err := database.DB.Where(
|
||||
"phone = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
|
||||
user.Phone, user.EnterpriseID,
|
||||
).First(&author).Error; err != nil {
|
||||
return fmt.Errorf("未找到有效的小红书作者记录: %w", err)
|
||||
}
|
||||
|
||||
if author.XHSCookie == "" {
|
||||
return errors.New("小红书Cookie已失效")
|
||||
}
|
||||
|
||||
// 3. 获取文章图片
|
||||
@@ -378,142 +389,130 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 解析Cookie(数据库中存储的是JSON字符串)
|
||||
var cookies interface{}
|
||||
if err := json.Unmarshal([]byte(user.XHSCookie), &cookies); err != nil {
|
||||
return fmt.Errorf("解析Cookie失败: %w,Cookie内容: %s", err, user.XHSCookie)
|
||||
}
|
||||
// 6. 准备发布数据:优先使用storage_state文件,其次使用login_state
|
||||
var cookiesData interface{}
|
||||
var loginStateData map[string]interface{}
|
||||
var useStorageStateMode bool
|
||||
|
||||
// 7. 构造发布配置
|
||||
publishConfig := map[string]interface{}{
|
||||
"cookies": cookies, // 解析后的Cookie对象或数组
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"tags": tags,
|
||||
}
|
||||
// 检查storage_state文件是否存在(根据手机号查找)
|
||||
storageStateFile := fmt.Sprintf("../backend/storage_states/xhs_%s.json", author.XHSPhone)
|
||||
if _, err := os.Stat(storageStateFile); err == nil {
|
||||
log.Printf("[调度器] 检测到storage_state文件: %s", storageStateFile)
|
||||
useStorageStateMode = true
|
||||
} else {
|
||||
log.Printf("[调度器] storage_state文件不存在,使用login_state或cookies模式")
|
||||
useStorageStateMode = false
|
||||
|
||||
// 决定本次发布使用的代理
|
||||
proxyToUse := config.AppConfig.Scheduler.Proxy
|
||||
if proxyToUse == "" && config.AppConfig.Scheduler.ProxyFetchURL != "" {
|
||||
if dynamicProxy, err := fetchProxyFromPool(); err != nil {
|
||||
log.Printf("[代理池] 获取代理失败: %v", err)
|
||||
} else if dynamicProxy != "" {
|
||||
proxyToUse = dynamicProxy
|
||||
log.Printf("[代理池] 使用动态代理: %s", proxyToUse)
|
||||
}
|
||||
}
|
||||
|
||||
// 注入代理和User-Agent(如果有配置)
|
||||
if proxyToUse != "" {
|
||||
publishConfig["proxy"] = proxyToUse
|
||||
}
|
||||
if ua := config.AppConfig.Scheduler.UserAgent; ua != "" {
|
||||
publishConfig["user_agent"] = ua
|
||||
}
|
||||
|
||||
// 8. 保存临时配置文件
|
||||
tempDir := filepath.Join("..", "backend", "temp")
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
configFile := filepath.Join(tempDir, fmt.Sprintf("publish_%d_%d.json", article.ID, time.Now().Unix()))
|
||||
configData, err := json.MarshalIndent(publishConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, configData, 0644); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
}
|
||||
defer os.Remove(configFile) // 发布完成后删除临时文件
|
||||
|
||||
// 9. 调用Python发布脚本
|
||||
backendDir := filepath.Join("..", "backend")
|
||||
pythonScript := filepath.Join(backendDir, "xhs_publish.py")
|
||||
pythonCmd := getPythonPath(backendDir)
|
||||
|
||||
cmd := exec.Command(pythonCmd, pythonScript, "--config", configFile)
|
||||
cmd.Dir = backendDir
|
||||
|
||||
// 设置超时
|
||||
if s.publishTimeout > 0 {
|
||||
timer := time.AfterFunc(time.Duration(s.publishTimeout)*time.Second, func() {
|
||||
cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop()
|
||||
}
|
||||
|
||||
// 捕获输出
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// 执行命令
|
||||
err = cmd.Run()
|
||||
|
||||
// 打印Python脚本日志
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("[Python日志-发布文案%d] %s", article.ID, stderr.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 更新文章状态为failed
|
||||
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("发布失败: %v", err))
|
||||
return fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 10. 解析发布结果
|
||||
// 注意:Python脚本可能输出日志到stdout,需要提取最后一行JSON
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 查找最后一个完整的JSON对象
|
||||
var result map[string]interface{}
|
||||
found := false
|
||||
|
||||
// 尝试从最后一行开始解析JSON
|
||||
lines := strings.Split(strings.TrimSpace(outputStr), "\n")
|
||||
|
||||
// 从后往前找第一个有效的JSON
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试解析为JSON(必须以{开头)
|
||||
if strings.HasPrefix(line, "{") {
|
||||
if err := json.Unmarshal([]byte(line), &result); err == nil {
|
||||
found = true
|
||||
log.Printf("成功解析JSON结果(第%d行): %s", i+1, line)
|
||||
break
|
||||
// 尝试解析为JSON对象
|
||||
if err := json.Unmarshal([]byte(author.XHSCookie), &loginStateData); err == nil {
|
||||
// 检查是否是login_state格式(包含cookies字段)
|
||||
if _, ok := loginStateData["cookies"]; ok {
|
||||
log.Printf("[调度器] 检测到login_state格式,将使用完整登录状态")
|
||||
cookiesData = loginStateData // 使用完整的login_state
|
||||
} else {
|
||||
// 可能是cookies数组
|
||||
log.Printf("[调度器] 检测到纯cookies格式")
|
||||
cookiesData = loginStateData
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("解析Cookie失败: %w,Cookie内容: %s", err, author.XHSCookie[:100])
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
errMsg := "Python脚本未返回有效JSON结果"
|
||||
s.updateArticleStatus(article.ID, "failed", errMsg)
|
||||
log.Printf("完整输出内容:\n%s", outputStr)
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("错误输出:\n%s", stderr.String())
|
||||
// 7. 调用FastAPI服务(使用浏览器池+预热)
|
||||
fastAPIURL := config.AppConfig.XHS.PythonServiceURL
|
||||
if fastAPIURL == "" {
|
||||
fastAPIURL = "http://localhost:8000" // 默认地址
|
||||
}
|
||||
publishEndpoint := fastAPIURL + "/api/xhs/publish-with-cookies"
|
||||
|
||||
// 构造请求体
|
||||
// 优先级:storage_state文件 > login_state > cookies
|
||||
var fullRequest map[string]interface{}
|
||||
if useStorageStateMode {
|
||||
// 模式1:使用storage_state文件(通过手机号查找)
|
||||
fullRequest = map[string]interface{}{
|
||||
"phone": author.XHSPhone, // 传递手机号,Python后端会根据手机号查找文件
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"topics": tags,
|
||||
}
|
||||
log.Printf("[调度器] 使用storage_state模式发布,手机号: %s", author.XHSPhone)
|
||||
} else if loginState, ok := cookiesData.(map[string]interface{}); ok {
|
||||
if _, hasLoginStateStructure := loginState["cookies"]; hasLoginStateStructure {
|
||||
// 模式2:完整的login_state格式
|
||||
fullRequest = map[string]interface{}{
|
||||
"login_state": loginState,
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"topics": tags,
|
||||
}
|
||||
log.Printf("[调度器] 使用login_state模式发布")
|
||||
} else {
|
||||
// 模式3:纺cookies格式
|
||||
fullRequest = map[string]interface{}{
|
||||
"cookies": loginState,
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"topics": tags,
|
||||
}
|
||||
log.Printf("[调度器] 使用cookies模式发布")
|
||||
}
|
||||
} else {
|
||||
// 兜底:直接发送
|
||||
fullRequest = map[string]interface{}{
|
||||
"cookies": cookiesData,
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"topics": tags,
|
||||
}
|
||||
return fmt.Errorf("%s, output: %s", errMsg, outputStr)
|
||||
}
|
||||
|
||||
// 11. 检查发布是否成功
|
||||
success, ok := result["success"].(bool)
|
||||
if !ok || !success {
|
||||
requestBody, err := json.Marshal(fullRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造请求数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送HTTP请求
|
||||
timeout := time.Duration(s.publishTimeout) * time.Second
|
||||
if s.publishTimeout <= 0 {
|
||||
timeout = 120 * time.Second // 默认120秒超时
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Post(publishEndpoint, "application/json", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("调用FastAPI服务失败: %v", err))
|
||||
return fmt.Errorf("调用FastAPI服务失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 9. 解析响应
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("解析FastAPI响应失败: %v", err))
|
||||
return fmt.Errorf("解析FastAPI响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 10. 检查发布是否成功
|
||||
code, ok := result["code"].(float64)
|
||||
if !ok || code != 0 {
|
||||
errMsg := "未知错误"
|
||||
if errStr, ok := result["error"].(string); ok {
|
||||
errMsg = errStr
|
||||
if msg, ok := result["message"].(string); ok {
|
||||
errMsg = msg
|
||||
}
|
||||
s.updateArticleStatus(article.ID, "failed", errMsg)
|
||||
return fmt.Errorf("发布失败: %s", errMsg)
|
||||
}
|
||||
|
||||
// 12. 更新文章状态为published
|
||||
// 11. 更新文章状态为published
|
||||
s.updateArticleStatus(article.ID, "published", "发布成功")
|
||||
|
||||
log.Printf("[使用FastAPI] 文章 %d 发布成功,享受浏览器池+预热加速", article.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user