Sfoglia il codice sorgente

同步每日数据

Ethanfly 20 ore fa
parent
commit
607e7e7e46

+ 0 - 14
client/src/components.d.ts

@@ -7,25 +7,19 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
-    ArrowDown: typeof import('@element-plus/icons-vue')['ArrowDown']
     BrowserTab: typeof import('./components/BrowserTab.vue')['default']
     CaptchaDialog: typeof import('./components/CaptchaDialog.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElBadge: typeof import('element-plus/es')['ElBadge']
-    ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
-    ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
-    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
-    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -35,7 +29,6 @@ declare module 'vue' {
     ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
-    ElHeader: typeof import('element-plus/es')['ElHeader']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
@@ -48,21 +41,14 @@ declare module 'vue' {
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
-    ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
-    ElText: typeof import('element-plus/es')['ElText']
-    ElTooltip: typeof import('element-plus/es')['ElTooltip']
-    ElUpload: typeof import('element-plus/es')['ElUpload']
-    Expand: typeof import('@element-plus/icons-vue')['Expand']
-    Fold: typeof import('@element-plus/icons-vue')['Fold']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
-    Setting: typeof import('@element-plus/icons-vue')['Setting']
     TaskProgressDialog: typeof import('./components/TaskProgressDialog.vue')['default']
   }
   export interface ComponentCustomProperties {

+ 18 - 0
database/schema.sql

@@ -183,3 +183,21 @@ CREATE TABLE IF NOT EXISTS operation_logs (
     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
     INDEX idx_log_user_time (user_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 作品每日统计表(记录每天的数据快照)
+CREATE TABLE IF NOT EXISTS work_day_statistics (
+    id INT PRIMARY KEY AUTO_INCREMENT,
+    work_id INT NOT NULL,
+    record_date DATE NOT NULL,
+    fans_count INT DEFAULT 0 COMMENT '粉丝数(来自账号)',
+    play_count INT DEFAULT 0 COMMENT '播放数',
+    like_count INT DEFAULT 0 COMMENT '点赞数',
+    comment_count INT DEFAULT 0 COMMENT '评论数',
+    share_count INT DEFAULT 0 COMMENT '分享数',
+    collect_count INT DEFAULT 0 COMMENT '收藏数',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_work_date (work_id, record_date),
+    INDEX idx_work_id (work_id),
+    INDEX idx_record_date (record_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品每日统计数据';

+ 259 - 2
server/python/app.py

@@ -17,14 +17,45 @@ import os
 import sys
 import argparse
 import traceback
-from datetime import datetime
+from datetime import datetime, date
 from pathlib import Path
 
+import pymysql
+from pymysql.cursors import DictCursor
+
 # 确保当前目录在 Python 路径中
 CURRENT_DIR = Path(__file__).parent.resolve()
 if str(CURRENT_DIR) not in sys.path:
     sys.path.insert(0, str(CURRENT_DIR))
 
+# 从 server/.env 文件加载环境变量
+def load_env_file():
+    """从 server/.env 文件加载环境变量"""
+    env_path = CURRENT_DIR.parent / '.env'
+    if env_path.exists():
+        print(f"[Config] Loading env from: {env_path}")
+        with open(env_path, 'r', encoding='utf-8') as f:
+            for line in f:
+                line = line.strip()
+                if line and not line.startswith('#') and '=' in line:
+                    key, value = line.split('=', 1)
+                    key = key.strip()
+                    value = value.strip()
+                    # 移除引号
+                    if value.startswith('"') and value.endswith('"'):
+                        value = value[1:-1]
+                    elif value.startswith("'") and value.endswith("'"):
+                        value = value[1:-1]
+                    # 只在环境变量未设置时加载
+                    if key not in os.environ:
+                        os.environ[key] = value
+                        print(f"[Config] Loaded: {key}=***" if 'PASSWORD' in key or 'SECRET' in key else f"[Config] Loaded: {key}={value}")
+    else:
+        print(f"[Config] .env file not found: {env_path}")
+
+# 加载环境变量
+load_env_file()
+
 from flask import Flask, request, jsonify
 from flask_cors import CORS
 
@@ -83,6 +114,31 @@ CORS(app)
 # 全局配置
 HEADLESS_MODE = os.environ.get('HEADLESS', 'true').lower() == 'true'
 
+# 数据库配置
+DB_CONFIG = {
+    'host': os.environ.get('DB_HOST', 'localhost'),
+    'port': int(os.environ.get('DB_PORT', 3306)),
+    'user': os.environ.get('DB_USERNAME', 'root'),
+    'password': os.environ.get('DB_PASSWORD', ''),
+    'database': os.environ.get('DB_DATABASE', 'media_manager'),
+    'charset': 'utf8mb4',
+    'cursorclass': DictCursor
+}
+print(f"[DB_CONFIG] host={DB_CONFIG['host']}, port={DB_CONFIG['port']}, user={DB_CONFIG['user']}, db={DB_CONFIG['database']}, pwd_len={len(DB_CONFIG['password'])}", flush=True)
+
+
+def get_db_connection():
+    """获取数据库连接"""
+    print(f"[DEBUG DB] 正在连接数据库...", flush=True)
+    print(f"[DEBUG DB] host={DB_CONFIG['host']}, port={DB_CONFIG['port']}, user={DB_CONFIG['user']}, db={DB_CONFIG['database']}", flush=True)
+    try:
+        conn = pymysql.connect(**DB_CONFIG)
+        print(f"[DEBUG DB] 数据库连接成功!", flush=True)
+        return conn
+    except Exception as e:
+        print(f"[DEBUG DB] 数据库连接失败: {e}", flush=True)
+        raise
+
 # ==================== 签名相关(小红书专用) ====================
 
 @app.route("/sign", methods=["POST"])
@@ -425,6 +481,205 @@ def get_works():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+# ==================== 保存作品日统计数据接口 ====================
+
+@app.route("/work_day_statistics", methods=["POST"])
+def save_work_day_statistics():
+    """
+    保存作品每日统计数据
+    当天的数据走更新流,日期变化走新增流
+    
+    请求体:
+    {
+        "statistics": [
+            {
+                "work_id": 1,
+                "fans_count": 1000,
+                "play_count": 5000,
+                "like_count": 200,
+                "comment_count": 50,
+                "share_count": 30,
+                "collect_count": 100
+            },
+            ...
+        ]
+    }
+    
+    响应:
+    {
+        "success": true,
+        "inserted": 5,
+        "updated": 3,
+        "message": "保存成功"
+    }
+    """
+    print("=" * 60, flush=True)
+    print("[DEBUG] ===== 进入 save_work_day_statistics 方法 =====", flush=True)
+    print(f"[DEBUG] 请求方法: {request.method}", flush=True)
+    print(f"[DEBUG] 请求数据: {request.json}", flush=True)
+    print("=" * 60, flush=True)
+    
+    try:
+        data = request.json
+        statistics_list = data.get("statistics", [])
+        
+        if not statistics_list:
+            return jsonify({"success": False, "error": "缺少 statistics 参数"}), 400
+        
+        today = date.today()
+        inserted_count = 0
+        updated_count = 0
+        
+        print(f"[WorkDayStatistics] 收到请求: {len(statistics_list)} 条统计数据")
+        
+        conn = get_db_connection()
+        try:
+            with conn.cursor() as cursor:
+                for stat in statistics_list:
+                    work_id = stat.get("work_id")
+                    if not work_id:
+                        continue
+                    
+                    fans_count = stat.get("fans_count", 0)
+                    play_count = stat.get("play_count", 0)
+                    like_count = stat.get("like_count", 0)
+                    comment_count = stat.get("comment_count", 0)
+                    share_count = stat.get("share_count", 0)
+                    collect_count = stat.get("collect_count", 0)
+                    
+                    # 检查当天是否已有记录
+                    cursor.execute(
+                        "SELECT id FROM work_day_statistics WHERE work_id = %s AND record_date = %s",
+                        (work_id, today)
+                    )
+                    existing = cursor.fetchone()
+                    
+                    if existing:
+                        # 更新已有记录
+                        cursor.execute(
+                            """UPDATE work_day_statistics 
+                               SET fans_count = %s, play_count = %s, like_count = %s, 
+                                   comment_count = %s, share_count = %s, collect_count = %s,
+                                   updated_at = NOW()
+                               WHERE id = %s""",
+                            (fans_count, play_count, like_count, comment_count, 
+                             share_count, collect_count, existing['id'])
+                        )
+                        updated_count += 1
+                    else:
+                        # 插入新记录
+                        cursor.execute(
+                            """INSERT INTO work_day_statistics 
+                               (work_id, record_date, fans_count, play_count, like_count, 
+                                comment_count, share_count, collect_count, created_at, updated_at)
+                               VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())""",
+                            (work_id, today, fans_count, play_count, like_count, 
+                             comment_count, share_count, collect_count)
+                        )
+                        inserted_count += 1
+                
+                conn.commit()
+        finally:
+            conn.close()
+        
+        print(f"[WorkDayStatistics] 完成: 新增 {inserted_count} 条, 更新 {updated_count} 条")
+        
+        return jsonify({
+            "success": True,
+            "inserted": inserted_count,
+            "updated": updated_count,
+            "message": f"保存成功: 新增 {inserted_count} 条, 更新 {updated_count} 条"
+        })
+        
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route("/work_day_statistics/batch", methods=["POST"])
+def get_work_statistics_history():
+    """
+    批量获取作品的历史统计数据
+    
+    请求体:
+    {
+        "work_ids": [1, 2, 3],
+        "start_date": "2025-01-01",  # 可选
+        "end_date": "2025-01-21"      # 可选
+    }
+    
+    响应:
+    {
+        "success": true,
+        "data": {
+            "1": [
+                {"record_date": "2025-01-20", "play_count": 100, ...},
+                {"record_date": "2025-01-21", "play_count": 150, ...}
+            ],
+            ...
+        }
+    }
+    """
+    try:
+        data = request.json
+        work_ids = data.get("work_ids", [])
+        start_date = data.get("start_date")
+        end_date = data.get("end_date")
+        
+        if not work_ids:
+            return jsonify({"success": False, "error": "缺少 work_ids 参数"}), 400
+        
+        conn = get_db_connection()
+        try:
+            with conn.cursor() as cursor:
+                # 构建查询
+                placeholders = ', '.join(['%s'] * len(work_ids))
+                sql = f"""SELECT work_id, record_date, fans_count, play_count, like_count, 
+                                 comment_count, share_count, collect_count
+                          FROM work_day_statistics 
+                          WHERE work_id IN ({placeholders})"""
+                params = list(work_ids)
+                
+                if start_date:
+                    sql += " AND record_date >= %s"
+                    params.append(start_date)
+                if end_date:
+                    sql += " AND record_date <= %s"
+                    params.append(end_date)
+                
+                sql += " ORDER BY work_id, record_date"
+                
+                cursor.execute(sql, params)
+                results = cursor.fetchall()
+        finally:
+            conn.close()
+        
+        # 按 work_id 分组
+        grouped_data = {}
+        for row in results:
+            work_id = str(row['work_id'])
+            if work_id not in grouped_data:
+                grouped_data[work_id] = []
+            grouped_data[work_id].append({
+                'record_date': row['record_date'].strftime('%Y-%m-%d') if row['record_date'] else None,
+                'fans_count': row['fans_count'],
+                'play_count': row['play_count'],
+                'like_count': row['like_count'],
+                'comment_count': row['comment_count'],
+                'share_count': row['share_count'],
+                'collect_count': row['collect_count']
+            })
+        
+        return jsonify({
+            "success": True,
+            "data": grouped_data
+        })
+        
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
 # ==================== 获取评论列表接口 ====================
 
 @app.route("/comments", methods=["POST"])
@@ -636,7 +891,7 @@ def index():
     """首页"""
     return jsonify({
         "name": "多平台视频发布服务",
-        "version": "1.1.0",
+        "version": "1.2.0",
         "endpoints": {
             "GET /": "服务信息",
             "GET /health": "健康检查",
@@ -645,6 +900,8 @@ def index():
             "POST /works": "获取作品列表",
             "POST /comments": "获取作品评论",
             "POST /all_comments": "获取所有作品评论",
+            "POST /work_day_statistics": "保存作品每日统计数据",
+            "POST /work_day_statistics/batch": "获取作品历史统计数据",
             "POST /check_cookie": "检查 Cookie",
             "POST /sign": "小红书签名"
         },

BIN
server/python/platforms/__pycache__/__init__.cpython-311.pyc


BIN
server/python/platforms/__pycache__/base.cpython-311.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/kuaishou.cpython-311.pyc


BIN
server/python/platforms/__pycache__/weixin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc


+ 3 - 0
server/python/requirements.txt

@@ -19,3 +19,6 @@ qrcode>=7.0
 
 # HTTP 请求
 requests>=2.28.0
+
+# MySQL 数据库连接
+pymysql>=1.0.0

+ 38 - 0
server/src/models/entities/WorkDayStatistics.ts

@@ -0,0 +1,38 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
+
+@Entity('work_day_statistics')
+@Index(['workId', 'recordDate'], { unique: true })
+export class WorkDayStatistics {
+  @PrimaryGeneratedColumn()
+  id!: number;
+
+  @Column({ name: 'work_id', type: 'int' })
+  workId!: number;
+
+  @Column({ name: 'record_date', type: 'date' })
+  recordDate!: Date;
+
+  @Column({ name: 'fans_count', type: 'int', default: 0, comment: '粉丝数(来自账号)' })
+  fansCount!: number;
+
+  @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
+  playCount!: number;
+
+  @Column({ name: 'like_count', type: 'int', default: 0, comment: '点赞数' })
+  likeCount!: number;
+
+  @Column({ name: 'comment_count', type: 'int', default: 0, comment: '评论数' })
+  commentCount!: number;
+
+  @Column({ name: 'share_count', type: 'int', default: 0, comment: '分享数' })
+  shareCount!: number;
+
+  @Column({ name: 'collect_count', type: 'int', default: 0, comment: '收藏数' })
+  collectCount!: number;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+}

+ 3 - 0
server/src/models/index.ts

@@ -11,6 +11,7 @@ import { Comment } from './entities/Comment.js';
 import { AnalyticsData } from './entities/AnalyticsData.js';
 import { OperationLog } from './entities/OperationLog.js';
 import { Work } from './entities/Work.js';
+import { WorkDayStatistics } from './entities/WorkDayStatistics.js';
 
 export const AppDataSource = new DataSource({
   type: 'mysql',
@@ -33,6 +34,7 @@ export const AppDataSource = new DataSource({
     AnalyticsData,
     OperationLog,
     Work,
+    WorkDayStatistics,
   ],
   charset: 'utf8mb4',
 });
@@ -56,4 +58,5 @@ export {
   AnalyticsData,
   OperationLog,
   Work,
+  WorkDayStatistics,
 };

+ 59 - 0
server/src/services/WorkService.ts

@@ -6,6 +6,8 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 
+const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
+
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
@@ -250,10 +252,67 @@ export class WorkService {
       logger.info(`Deleted ${deletedCount} works that no longer exist on platform for account ${account.id}`);
     }
 
+    // 保存每日统计数据
+    try {
+      await this.saveWorkDayStatistics(account);
+    } catch (error) {
+      logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
+    }
+
     return syncedCount;
   }
 
   /**
+   * 保存作品每日统计数据
+   */
+  private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
+    // 获取该账号下所有作品
+    const works = await this.workRepository.find({
+      where: { accountId: account.id },
+    });
+
+    if (works.length === 0) {
+      logger.info(`[SaveWorkDayStatistics] No works found for account ${account.id}`);
+      return;
+    }
+
+    // 构建统计数据列表
+    const statisticsList = works.map(work => ({
+      work_id: work.id,
+      fans_count: account.fansCount || 0,
+      play_count: work.playCount || 0,
+      like_count: work.likeCount || 0,
+      comment_count: work.commentCount || 0,
+      share_count: work.shareCount || 0,
+      collect_count: work.collectCount || 0,
+    }));
+
+    logger.info(`[SaveWorkDayStatistics] Saving ${statisticsList.length} work statistics for account ${account.id}`);
+
+    // 调用 Python API 保存统计数据
+    try {
+      const response = await fetch(`${PYTHON_SERVICE_URL}/work_day_statistics`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({ statistics: statisticsList }),
+      });
+
+      const result = await response.json();
+
+      if (result.success) {
+        logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`);
+      } else {
+        logger.error(`[SaveWorkDayStatistics] Failed: ${result.error}`);
+      }
+    } catch (error) {
+      logger.error(`[SaveWorkDayStatistics] API call failed:`, error);
+      throw error;
+    }
+  }
+
+  /**
    * 标准化状态
    */
   private normalizeStatus(status: string): string {