This commit is contained in:
sjk
2026-01-06 19:36:42 +08:00
parent 15b579d64a
commit 19942144fb
261 changed files with 24034 additions and 5477 deletions

View File

@@ -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失败: %wCookie内容: %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失败: %wCookie内容: %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
}