app.py 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 多平台视频发布服务 - 统一入口
  5. 支持平台: 抖音、小红书、视频号、快手
  6. 参考项目: matrix (https://github.com/kebenxiaoming/matrix)
  7. 使用方式:
  8. python app.py # 启动 HTTP 服务 (端口 5005)
  9. python app.py --port 8080 # 指定端口
  10. python app.py --headless false # 显示浏览器窗口
  11. """
  12. import asyncio
  13. import os
  14. import sys
  15. import argparse
  16. import traceback
  17. from datetime import datetime, date
  18. from pathlib import Path
  19. import pymysql
  20. from pymysql.cursors import DictCursor
  21. # 确保当前目录在 Python 路径中
  22. CURRENT_DIR = Path(__file__).parent.resolve()
  23. if str(CURRENT_DIR) not in sys.path:
  24. sys.path.insert(0, str(CURRENT_DIR))
  25. # 从 server/.env 文件加载环境变量
  26. def load_env_file():
  27. """从 server/.env 文件加载环境变量"""
  28. env_path = CURRENT_DIR.parent / '.env'
  29. if env_path.exists():
  30. print(f"[Config] Loading env from: {env_path}")
  31. with open(env_path, 'r', encoding='utf-8') as f:
  32. for line in f:
  33. line = line.strip()
  34. if line and not line.startswith('#') and '=' in line:
  35. key, value = line.split('=', 1)
  36. key = key.strip()
  37. value = value.strip()
  38. # 移除引号
  39. if value.startswith('"') and value.endswith('"'):
  40. value = value[1:-1]
  41. elif value.startswith("'") and value.endswith("'"):
  42. value = value[1:-1]
  43. # 只在环境变量未设置时加载
  44. if key not in os.environ:
  45. os.environ[key] = value
  46. print(f"[Config] Loaded: {key}=***" if 'PASSWORD' in key or 'SECRET' in key else f"[Config] Loaded: {key}={value}")
  47. else:
  48. print(f"[Config] .env file not found: {env_path}")
  49. # 加载环境变量
  50. load_env_file()
  51. from flask import Flask, request, jsonify
  52. from flask_cors import CORS
  53. from platforms import get_publisher, PLATFORM_MAP
  54. from platforms.base import PublishParams
  55. def parse_datetime(date_str: str):
  56. """解析日期时间字符串"""
  57. if not date_str:
  58. return None
  59. formats = [
  60. "%Y-%m-%d %H:%M:%S",
  61. "%Y-%m-%d %H:%M",
  62. "%Y/%m/%d %H:%M:%S",
  63. "%Y/%m/%d %H:%M",
  64. "%Y-%m-%dT%H:%M:%S",
  65. "%Y-%m-%dT%H:%M:%SZ",
  66. ]
  67. for fmt in formats:
  68. try:
  69. return datetime.strptime(date_str, fmt)
  70. except ValueError:
  71. continue
  72. return None
  73. def validate_video_file(video_path: str) -> bool:
  74. """验证视频文件是否有效"""
  75. if not video_path:
  76. return False
  77. if not os.path.exists(video_path):
  78. return False
  79. if not os.path.isfile(video_path):
  80. return False
  81. valid_extensions = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm']
  82. ext = os.path.splitext(video_path)[1].lower()
  83. if ext not in valid_extensions:
  84. return False
  85. if os.path.getsize(video_path) < 1024:
  86. return False
  87. return True
  88. # 创建 Flask 应用
  89. app = Flask(__name__)
  90. CORS(app)
  91. # 全局配置
  92. HEADLESS_MODE = os.environ.get('HEADLESS', 'true').lower() == 'true'
  93. print(f"[Config] HEADLESS env value: '{os.environ.get('HEADLESS', 'NOT SET')}'", flush=True)
  94. print(f"[Config] HEADLESS_MODE: {HEADLESS_MODE}", flush=True)
  95. # 数据库配置
  96. DB_CONFIG = {
  97. 'host': os.environ.get('DB_HOST', 'localhost'),
  98. 'port': int(os.environ.get('DB_PORT', 3306)),
  99. 'user': os.environ.get('DB_USERNAME', 'root'),
  100. 'password': os.environ.get('DB_PASSWORD', ''),
  101. 'database': os.environ.get('DB_DATABASE', 'media_manager'),
  102. 'charset': 'utf8mb4',
  103. 'cursorclass': DictCursor
  104. }
  105. 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)
  106. def get_db_connection():
  107. """获取数据库连接"""
  108. print(f"[DEBUG DB] 正在连接数据库...", flush=True)
  109. print(f"[DEBUG DB] host={DB_CONFIG['host']}, port={DB_CONFIG['port']}, user={DB_CONFIG['user']}, db={DB_CONFIG['database']}", flush=True)
  110. try:
  111. conn = pymysql.connect(**DB_CONFIG)
  112. print(f"[DEBUG DB] 数据库连接成功!", flush=True)
  113. return conn
  114. except Exception as e:
  115. print(f"[DEBUG DB] 数据库连接失败: {e}", flush=True)
  116. raise
  117. # ==================== 签名相关(小红书专用) ====================
  118. @app.route("/sign", methods=["POST"])
  119. def sign_endpoint():
  120. """小红书签名接口"""
  121. try:
  122. from platforms.xiaohongshu import XiaohongshuPublisher
  123. data = request.json
  124. publisher = XiaohongshuPublisher(headless=True)
  125. result = asyncio.run(publisher.get_sign(
  126. data.get("uri", ""),
  127. data.get("data"),
  128. data.get("a1", ""),
  129. data.get("web_session", "")
  130. ))
  131. return jsonify(result)
  132. except Exception as e:
  133. traceback.print_exc()
  134. return jsonify({"error": str(e)}), 500
  135. # ==================== 统一发布接口 ====================
  136. @app.route("/publish", methods=["POST"])
  137. def publish_video():
  138. """
  139. 统一发布接口
  140. 请求体:
  141. {
  142. "platform": "douyin", # douyin | xiaohongshu | weixin | kuaishou
  143. "cookie": "cookie字符串或JSON",
  144. "title": "视频标题",
  145. "description": "视频描述(可选)",
  146. "video_path": "视频文件绝对路径",
  147. "cover_path": "封面图片绝对路径(可选)",
  148. "tags": ["话题1", "话题2"],
  149. "post_time": "定时发布时间(可选,格式:2024-01-20 12:00:00)",
  150. "location": "位置(可选,默认:重庆市)"
  151. }
  152. 响应:
  153. {
  154. "success": true,
  155. "platform": "douyin",
  156. "video_id": "xxx",
  157. "video_url": "xxx",
  158. "message": "发布成功"
  159. }
  160. """
  161. try:
  162. data = request.json
  163. # 获取参数
  164. platform = data.get("platform", "").lower()
  165. cookie_str = data.get("cookie", "")
  166. title = data.get("title", "")
  167. description = data.get("description", "")
  168. video_path = data.get("video_path", "")
  169. cover_path = data.get("cover_path")
  170. tags = data.get("tags", [])
  171. post_time = data.get("post_time")
  172. location = data.get("location", "重庆市")
  173. # 调试日志
  174. print(f"[Publish] 收到请求: platform={platform}, title={title}, video_path={video_path}")
  175. # 参数验证
  176. if not platform:
  177. print("[Publish] 错误: 缺少 platform 参数")
  178. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  179. if platform not in PLATFORM_MAP:
  180. print(f"[Publish] 错误: 不支持的平台 {platform}")
  181. return jsonify({
  182. "success": False,
  183. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  184. }), 400
  185. if not cookie_str:
  186. print("[Publish] 错误: 缺少 cookie 参数")
  187. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  188. if not title:
  189. print("[Publish] 错误: 缺少 title 参数")
  190. return jsonify({"success": False, "error": "缺少 title 参数"}), 400
  191. if not video_path:
  192. print("[Publish] 错误: 缺少 video_path 参数")
  193. return jsonify({"success": False, "error": "缺少 video_path 参数"}), 400
  194. # 视频文件验证(增加详细信息)
  195. if not os.path.exists(video_path):
  196. print(f"[Publish] 错误: 视频文件不存在: {video_path}")
  197. return jsonify({"success": False, "error": f"视频文件不存在: {video_path}"}), 400
  198. if not os.path.isfile(video_path):
  199. print(f"[Publish] 错误: 路径不是文件: {video_path}")
  200. return jsonify({"success": False, "error": f"路径不是文件: {video_path}"}), 400
  201. # 解析发布时间
  202. publish_date = parse_datetime(post_time) if post_time else None
  203. # 创建发布参数
  204. params = PublishParams(
  205. title=title,
  206. video_path=video_path,
  207. description=description,
  208. cover_path=cover_path,
  209. tags=tags,
  210. publish_date=publish_date,
  211. location=location
  212. )
  213. print("=" * 60)
  214. print(f"[Publish] 平台: {platform}")
  215. print(f"[Publish] 标题: {title}")
  216. print(f"[Publish] 视频: {video_path}")
  217. print(f"[Publish] 封面: {cover_path}")
  218. print(f"[Publish] 话题: {tags}")
  219. print(f"[Publish] 定时: {publish_date}")
  220. print("=" * 60)
  221. # 获取对应平台的发布器
  222. PublisherClass = get_publisher(platform)
  223. publisher = PublisherClass(headless=HEADLESS_MODE)
  224. # 执行发布
  225. result = asyncio.run(publisher.run(cookie_str, params))
  226. response_data = {
  227. "success": result.success,
  228. "platform": result.platform,
  229. "video_id": result.video_id,
  230. "video_url": result.video_url,
  231. "message": result.message,
  232. "error": result.error,
  233. "need_captcha": result.need_captcha,
  234. "captcha_type": result.captcha_type
  235. }
  236. # 如果需要验证码,打印明确的日志
  237. if result.need_captcha:
  238. print(f"[Publish] 需要验证码: type={result.captcha_type}")
  239. return jsonify(response_data)
  240. except Exception as e:
  241. traceback.print_exc()
  242. return jsonify({"success": False, "error": str(e)}), 500
  243. # ==================== 批量发布接口 ====================
  244. @app.route("/publish/batch", methods=["POST"])
  245. def publish_batch():
  246. """
  247. 批量发布接口 - 发布到多个平台
  248. 请求体:
  249. {
  250. "platforms": ["douyin", "xiaohongshu"],
  251. "cookies": {
  252. "douyin": "cookie字符串",
  253. "xiaohongshu": "cookie字符串"
  254. },
  255. "title": "视频标题",
  256. "video_path": "视频文件绝对路径",
  257. ...
  258. }
  259. """
  260. try:
  261. data = request.json
  262. platforms = data.get("platforms", [])
  263. cookies = data.get("cookies", {})
  264. if not platforms:
  265. return jsonify({"success": False, "error": "缺少 platforms 参数"}), 400
  266. results = []
  267. for platform in platforms:
  268. platform = platform.lower()
  269. cookie_str = cookies.get(platform, "")
  270. if not cookie_str:
  271. results.append({
  272. "platform": platform,
  273. "success": False,
  274. "error": f"缺少 {platform} 的 cookie"
  275. })
  276. continue
  277. try:
  278. # 创建参数
  279. params = PublishParams(
  280. title=data.get("title", ""),
  281. video_path=data.get("video_path", ""),
  282. description=data.get("description", ""),
  283. cover_path=data.get("cover_path"),
  284. tags=data.get("tags", []),
  285. publish_date=parse_datetime(data.get("post_time")),
  286. location=data.get("location", "重庆市")
  287. )
  288. # 发布
  289. PublisherClass = get_publisher(platform)
  290. publisher = PublisherClass(headless=HEADLESS_MODE)
  291. result = asyncio.run(publisher.run(cookie_str, params))
  292. results.append({
  293. "platform": result.platform,
  294. "success": result.success,
  295. "video_id": result.video_id,
  296. "message": result.message,
  297. "error": result.error
  298. })
  299. except Exception as e:
  300. results.append({
  301. "platform": platform,
  302. "success": False,
  303. "error": str(e)
  304. })
  305. # 统计成功/失败数量
  306. success_count = sum(1 for r in results if r.get("success"))
  307. return jsonify({
  308. "success": success_count > 0,
  309. "total": len(platforms),
  310. "success_count": success_count,
  311. "fail_count": len(platforms) - success_count,
  312. "results": results
  313. })
  314. except Exception as e:
  315. traceback.print_exc()
  316. return jsonify({"success": False, "error": str(e)}), 500
  317. # ==================== Cookie 验证接口 ====================
  318. @app.route("/check_cookie", methods=["POST"])
  319. def check_cookie():
  320. """检查 cookie 是否有效"""
  321. try:
  322. data = request.json
  323. platform = data.get("platform", "").lower()
  324. cookie_str = data.get("cookie", "")
  325. if not cookie_str:
  326. return jsonify({"valid": False, "error": "缺少 cookie 参数"}), 400
  327. # 目前只支持小红书的 cookie 验证
  328. if platform == "xiaohongshu":
  329. try:
  330. from platforms.xiaohongshu import XiaohongshuPublisher, XHS_SDK_AVAILABLE
  331. if XHS_SDK_AVAILABLE:
  332. from xhs import XhsClient
  333. publisher = XiaohongshuPublisher()
  334. xhs_client = XhsClient(cookie_str, sign=publisher.sign_sync)
  335. info = xhs_client.get_self_info()
  336. if info:
  337. return jsonify({
  338. "valid": True,
  339. "user_info": {
  340. "user_id": info.get("user_id"),
  341. "nickname": info.get("nickname"),
  342. "avatar": info.get("images")
  343. }
  344. })
  345. except Exception as e:
  346. return jsonify({"valid": False, "error": str(e)})
  347. # 其他平台返回格式正确但未验证
  348. return jsonify({
  349. "valid": True,
  350. "message": "Cookie 格式正确,但未进行在线验证"
  351. })
  352. except Exception as e:
  353. traceback.print_exc()
  354. return jsonify({"valid": False, "error": str(e)})
  355. # ==================== 获取作品列表接口 ====================
  356. @app.route("/works", methods=["POST"])
  357. def get_works():
  358. """
  359. 获取作品列表
  360. 请求体:
  361. {
  362. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  363. "cookie": "cookie字符串或JSON",
  364. "page": 0, # 页码(从0开始,可选,默认0)
  365. "page_size": 20 # 每页数量(可选,默认20)
  366. }
  367. 响应:
  368. {
  369. "success": true,
  370. "platform": "douyin",
  371. "works": [...],
  372. "total": 100,
  373. "has_more": true
  374. }
  375. """
  376. try:
  377. data = request.json
  378. platform = data.get("platform", "").lower()
  379. cookie_str = data.get("cookie", "")
  380. page = data.get("page", 0)
  381. page_size = data.get("page_size", 20)
  382. print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}")
  383. if not platform:
  384. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  385. if platform not in PLATFORM_MAP:
  386. return jsonify({
  387. "success": False,
  388. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  389. }), 400
  390. if not cookie_str:
  391. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  392. # 获取对应平台的发布器
  393. PublisherClass = get_publisher(platform)
  394. publisher = PublisherClass(headless=HEADLESS_MODE)
  395. # 执行获取作品
  396. result = asyncio.run(publisher.run_get_works(cookie_str, page, page_size))
  397. return jsonify(result.to_dict())
  398. except Exception as e:
  399. traceback.print_exc()
  400. return jsonify({"success": False, "error": str(e)}), 500
  401. # ==================== 保存作品日统计数据接口 ====================
  402. @app.route("/work_day_statistics", methods=["POST"])
  403. def save_work_day_statistics():
  404. """
  405. 保存作品每日统计数据
  406. 当天的数据走更新流,日期变化走新增流
  407. 请求体:
  408. {
  409. "statistics": [
  410. {
  411. "work_id": 1,
  412. "fans_count": 1000,
  413. "play_count": 5000,
  414. "like_count": 200,
  415. "comment_count": 50,
  416. "share_count": 30,
  417. "collect_count": 100
  418. },
  419. ...
  420. ]
  421. }
  422. 响应:
  423. {
  424. "success": true,
  425. "inserted": 5,
  426. "updated": 3,
  427. "message": "保存成功"
  428. }
  429. """
  430. print("=" * 60, flush=True)
  431. print("[DEBUG] ===== 进入 save_work_day_statistics 方法 =====", flush=True)
  432. print(f"[DEBUG] 请求方法: {request.method}", flush=True)
  433. print(f"[DEBUG] 请求数据: {request.json}", flush=True)
  434. print("=" * 60, flush=True)
  435. try:
  436. data = request.json
  437. statistics_list = data.get("statistics", [])
  438. if not statistics_list:
  439. return jsonify({"success": False, "error": "缺少 statistics 参数"}), 400
  440. today = date.today()
  441. inserted_count = 0
  442. updated_count = 0
  443. print(f"[WorkDayStatistics] 收到请求: {len(statistics_list)} 条统计数据")
  444. conn = get_db_connection()
  445. try:
  446. with conn.cursor() as cursor:
  447. for stat in statistics_list:
  448. work_id = stat.get("work_id")
  449. if not work_id:
  450. continue
  451. fans_count = stat.get("fans_count", 0)
  452. play_count = stat.get("play_count", 0)
  453. like_count = stat.get("like_count", 0)
  454. comment_count = stat.get("comment_count", 0)
  455. share_count = stat.get("share_count", 0)
  456. collect_count = stat.get("collect_count", 0)
  457. # 检查当天是否已有记录
  458. cursor.execute(
  459. "SELECT id FROM work_day_statistics WHERE work_id = %s AND record_date = %s",
  460. (work_id, today)
  461. )
  462. existing = cursor.fetchone()
  463. if existing:
  464. # 更新已有记录
  465. cursor.execute(
  466. """UPDATE work_day_statistics
  467. SET fans_count = %s, play_count = %s, like_count = %s,
  468. comment_count = %s, share_count = %s, collect_count = %s,
  469. updated_at = NOW()
  470. WHERE id = %s""",
  471. (fans_count, play_count, like_count, comment_count,
  472. share_count, collect_count, existing['id'])
  473. )
  474. updated_count += 1
  475. else:
  476. # 插入新记录
  477. cursor.execute(
  478. """INSERT INTO work_day_statistics
  479. (work_id, record_date, fans_count, play_count, like_count,
  480. comment_count, share_count, collect_count, created_at, updated_at)
  481. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())""",
  482. (work_id, today, fans_count, play_count, like_count,
  483. comment_count, share_count, collect_count)
  484. )
  485. inserted_count += 1
  486. conn.commit()
  487. finally:
  488. conn.close()
  489. print(f"[WorkDayStatistics] 完成: 新增 {inserted_count} 条, 更新 {updated_count} 条")
  490. return jsonify({
  491. "success": True,
  492. "inserted": inserted_count,
  493. "updated": updated_count,
  494. "message": f"保存成功: 新增 {inserted_count} 条, 更新 {updated_count} 条"
  495. })
  496. except Exception as e:
  497. traceback.print_exc()
  498. return jsonify({"success": False, "error": str(e)}), 500
  499. @app.route("/work_day_statistics/trend", methods=["GET"])
  500. def get_statistics_trend():
  501. """
  502. 获取数据趋势(用于 Dashboard 数据看板 和 数据分析页面)
  503. 查询参数:
  504. user_id: 用户ID (必填)
  505. days: 天数 (可选,默认7天,最大30天) - 与 start_date/end_date 二选一
  506. start_date: 开始日期 (可选,格式 YYYY-MM-DD)
  507. end_date: 结束日期 (可选,格式 YYYY-MM-DD)
  508. account_id: 账号ID (可选,不填则查询所有账号)
  509. 响应:
  510. {
  511. "success": true,
  512. "data": {
  513. "dates": ["01-16", "01-17", "01-18", ...],
  514. "fans": [100, 120, 130, ...],
  515. "views": [1000, 1200, 1500, ...],
  516. "likes": [50, 60, 70, ...],
  517. "comments": [10, 12, 15, ...],
  518. "shares": [5, 6, 8, ...],
  519. "collects": [20, 25, 30, ...]
  520. }
  521. }
  522. """
  523. try:
  524. user_id = request.args.get("user_id")
  525. days = request.args.get("days")
  526. start_date = request.args.get("start_date")
  527. end_date = request.args.get("end_date")
  528. account_id = request.args.get("account_id")
  529. if not user_id:
  530. return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
  531. conn = get_db_connection()
  532. try:
  533. with conn.cursor() as cursor:
  534. # 构建查询:关联 works 表获取用户的作品,然后汇总统计数据
  535. # 注意:粉丝数是账号级别的数据,每个账号每天只取一个值(使用 MAX)
  536. # 其他指标(播放、点赞等)是作品级别的数据,需要累加
  537. sql = """
  538. SELECT
  539. record_date,
  540. SUM(account_fans) as total_fans,
  541. SUM(account_views) as total_views,
  542. SUM(account_likes) as total_likes,
  543. SUM(account_comments) as total_comments,
  544. SUM(account_shares) as total_shares,
  545. SUM(account_collects) as total_collects
  546. FROM (
  547. SELECT
  548. wds.record_date,
  549. w.accountId,
  550. MAX(wds.fans_count) as account_fans,
  551. SUM(wds.play_count) as account_views,
  552. SUM(wds.like_count) as account_likes,
  553. SUM(wds.comment_count) as account_comments,
  554. SUM(wds.share_count) as account_shares,
  555. SUM(wds.collect_count) as account_collects
  556. FROM work_day_statistics wds
  557. INNER JOIN works w ON wds.work_id = w.id
  558. WHERE w.userId = %s
  559. """
  560. params = [user_id]
  561. # 支持两种日期筛选方式:start_date/end_date 或 days
  562. if start_date and end_date:
  563. sql += " AND wds.record_date >= %s AND wds.record_date <= %s"
  564. params.extend([start_date, end_date])
  565. else:
  566. # 默认使用 days 参数
  567. days_value = min(int(days or 7), 30)
  568. sql += " AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)"
  569. params.append(days_value)
  570. if account_id:
  571. sql += " AND w.accountId = %s"
  572. params.append(account_id)
  573. sql += """
  574. GROUP BY wds.record_date, w.accountId
  575. ) as account_stats
  576. GROUP BY record_date
  577. ORDER BY record_date ASC
  578. """
  579. cursor.execute(sql, params)
  580. results = cursor.fetchall()
  581. # 构建响应数据
  582. dates = []
  583. fans = []
  584. views = []
  585. likes = []
  586. comments = []
  587. shares = []
  588. collects = []
  589. for row in results:
  590. # 格式化日期为 "MM-DD" 格式
  591. record_date = row['record_date']
  592. if isinstance(record_date, str):
  593. dates.append(record_date[5:10]) # "2026-01-16" -> "01-16"
  594. else:
  595. dates.append(record_date.strftime("%m-%d"))
  596. # 确保返回整数类型
  597. fans.append(int(row['total_fans'] or 0))
  598. views.append(int(row['total_views'] or 0))
  599. likes.append(int(row['total_likes'] or 0))
  600. comments.append(int(row['total_comments'] or 0))
  601. shares.append(int(row['total_shares'] or 0))
  602. collects.append(int(row['total_collects'] or 0))
  603. # 如果没有数据,生成空的日期范围
  604. if not dates:
  605. from datetime import timedelta
  606. today = date.today()
  607. for i in range(days, 0, -1):
  608. d = today - timedelta(days=i-1)
  609. dates.append(d.strftime("%m-%d"))
  610. fans.append(0)
  611. views.append(0)
  612. likes.append(0)
  613. comments.append(0)
  614. shares.append(0)
  615. collects.append(0)
  616. return jsonify({
  617. "success": True,
  618. "data": {
  619. "dates": dates,
  620. "fans": fans,
  621. "views": views,
  622. "likes": likes,
  623. "comments": comments,
  624. "shares": shares,
  625. "collects": collects
  626. }
  627. })
  628. finally:
  629. conn.close()
  630. except Exception as e:
  631. traceback.print_exc()
  632. return jsonify({"success": False, "error": str(e)}), 500
  633. @app.route("/work_day_statistics/platforms", methods=["GET"])
  634. def get_statistics_by_platform():
  635. """
  636. 按平台分组获取统计数据(用于数据分析页面的平台对比)
  637. 数据来源:
  638. - 粉丝数:从 platform_accounts 表获取(账号级别数据)
  639. - 播放量/点赞/评论/收藏:从 work_day_statistics 表按平台汇总
  640. - 粉丝增量:通过比较区间内最早和最新的粉丝数计算
  641. 查询参数:
  642. user_id: 用户ID (必填)
  643. days: 天数 (可选,默认30天,最大30天) - 与 start_date/end_date 二选一
  644. start_date: 开始日期 (可选,格式 YYYY-MM-DD)
  645. end_date: 结束日期 (可选,格式 YYYY-MM-DD)
  646. 响应:
  647. {
  648. "success": true,
  649. "data": [
  650. {
  651. "platform": "douyin",
  652. "fansCount": 1000,
  653. "fansIncrease": 50,
  654. "viewsCount": 5000,
  655. "likesCount": 200,
  656. "commentsCount": 30,
  657. "collectsCount": 100
  658. },
  659. ...
  660. ]
  661. }
  662. """
  663. try:
  664. user_id = request.args.get("user_id")
  665. days = request.args.get("days")
  666. start_date = request.args.get("start_date")
  667. end_date = request.args.get("end_date")
  668. if not user_id:
  669. return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
  670. conn = get_db_connection()
  671. try:
  672. with conn.cursor() as cursor:
  673. # 简化查询:按平台分组获取统计数据
  674. # 1. 从 platform_accounts 获取当前粉丝数(注意:字段名是下划线命名 fans_count, user_id)
  675. # 2. 从 work_day_statistics 获取播放量等累计数据
  676. # 根据日期参数构建日期条件
  677. if start_date and end_date:
  678. date_condition = "wds.record_date >= %s AND wds.record_date <= %s"
  679. date_params = [start_date, end_date]
  680. else:
  681. days_value = min(int(days or 30), 30)
  682. date_condition = "wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)"
  683. date_params = [days_value]
  684. # 注意:数据库存储的是累积值,所以区间增量 = 最后一天 - 第一天
  685. sql = f"""
  686. SELECT
  687. pa.platform,
  688. pa.fans_count as current_fans,
  689. COALESCE(last_day.views, 0) - COALESCE(first_day.views, 0) as viewsIncrease,
  690. COALESCE(last_day.likes, 0) - COALESCE(first_day.likes, 0) as likesIncrease,
  691. COALESCE(last_day.comments, 0) - COALESCE(first_day.comments, 0) as commentsIncrease,
  692. COALESCE(last_day.collects, 0) - COALESCE(first_day.collects, 0) as collectsIncrease,
  693. COALESCE(first_day.fans, pa.fans_count) as earliest_fans
  694. FROM platform_accounts pa
  695. LEFT JOIN (
  696. -- 获取每个账号在区间内第一天的累积值
  697. SELECT
  698. accountId, fans, views, likes, comments, collects
  699. FROM (
  700. SELECT
  701. w.accountId,
  702. MAX(wds.fans_count) as fans,
  703. SUM(wds.play_count) as views,
  704. SUM(wds.like_count) as likes,
  705. SUM(wds.comment_count) as comments,
  706. SUM(wds.collect_count) as collects,
  707. wds.record_date,
  708. ROW_NUMBER() OVER (PARTITION BY w.accountId ORDER BY wds.record_date ASC) as rn
  709. FROM work_day_statistics wds
  710. INNER JOIN works w ON wds.work_id = w.id
  711. WHERE w.userId = %s
  712. AND {date_condition}
  713. GROUP BY w.accountId, wds.record_date
  714. ) ranked
  715. WHERE rn = 1
  716. ) first_day ON pa.id = first_day.accountId
  717. LEFT JOIN (
  718. -- 获取每个账号在区间内最后一天的累积值
  719. SELECT
  720. accountId, fans, views, likes, comments, collects
  721. FROM (
  722. SELECT
  723. w.accountId,
  724. MAX(wds.fans_count) as fans,
  725. SUM(wds.play_count) as views,
  726. SUM(wds.like_count) as likes,
  727. SUM(wds.comment_count) as comments,
  728. SUM(wds.collect_count) as collects,
  729. wds.record_date,
  730. ROW_NUMBER() OVER (PARTITION BY w.accountId ORDER BY wds.record_date DESC) as rn
  731. FROM work_day_statistics wds
  732. INNER JOIN works w ON wds.work_id = w.id
  733. WHERE w.userId = %s
  734. AND {date_condition}
  735. GROUP BY w.accountId, wds.record_date
  736. ) ranked
  737. WHERE rn = 1
  738. ) last_day ON pa.id = last_day.accountId
  739. WHERE pa.user_id = %s
  740. ORDER BY current_fans DESC
  741. """
  742. # 构建参数列表:first_day子查询(user_id + date_params) + last_day子查询(user_id + date_params) + 主查询(user_id)
  743. params = [user_id] + date_params + [user_id] + date_params + [user_id]
  744. cursor.execute(sql, params)
  745. results = cursor.fetchall()
  746. # 构建响应数据
  747. platform_data = []
  748. for row in results:
  749. current_fans = int(row['current_fans'] or 0)
  750. earliest_fans = int(row['earliest_fans'] or current_fans)
  751. fans_increase = current_fans - earliest_fans
  752. platform_data.append({
  753. "platform": row['platform'],
  754. "fansCount": current_fans,
  755. "fansIncrease": fans_increase,
  756. "viewsCount": int(row['viewsIncrease'] or 0), # 区间增量
  757. "likesCount": int(row['likesIncrease'] or 0), # 区间增量
  758. "commentsCount": int(row['commentsIncrease'] or 0), # 区间增量
  759. "collectsCount": int(row['collectsIncrease'] or 0), # 区间增量
  760. })
  761. print(f"[PlatformStats] 返回 {len(platform_data)} 个平台的数据")
  762. return jsonify({
  763. "success": True,
  764. "data": platform_data
  765. })
  766. finally:
  767. conn.close()
  768. except Exception as e:
  769. traceback.print_exc()
  770. return jsonify({"success": False, "error": str(e)}), 500
  771. @app.route("/work_day_statistics/batch", methods=["POST"])
  772. def get_work_statistics_history():
  773. """
  774. 批量获取作品的历史统计数据
  775. 请求体:
  776. {
  777. "work_ids": [1, 2, 3],
  778. "start_date": "2025-01-01", # 可选
  779. "end_date": "2025-01-21" # 可选
  780. }
  781. 响应:
  782. {
  783. "success": true,
  784. "data": {
  785. "1": [
  786. {"record_date": "2025-01-20", "play_count": 100, ...},
  787. {"record_date": "2025-01-21", "play_count": 150, ...}
  788. ],
  789. ...
  790. }
  791. }
  792. """
  793. try:
  794. data = request.json
  795. work_ids = data.get("work_ids", [])
  796. start_date = data.get("start_date")
  797. end_date = data.get("end_date")
  798. if not work_ids:
  799. return jsonify({"success": False, "error": "缺少 work_ids 参数"}), 400
  800. conn = get_db_connection()
  801. try:
  802. with conn.cursor() as cursor:
  803. # 构建查询
  804. placeholders = ', '.join(['%s'] * len(work_ids))
  805. sql = f"""SELECT work_id, record_date, fans_count, play_count, like_count,
  806. comment_count, share_count, collect_count
  807. FROM work_day_statistics
  808. WHERE work_id IN ({placeholders})"""
  809. params = list(work_ids)
  810. if start_date:
  811. sql += " AND record_date >= %s"
  812. params.append(start_date)
  813. if end_date:
  814. sql += " AND record_date <= %s"
  815. params.append(end_date)
  816. sql += " ORDER BY work_id, record_date"
  817. cursor.execute(sql, params)
  818. results = cursor.fetchall()
  819. finally:
  820. conn.close()
  821. # 按 work_id 分组
  822. grouped_data = {}
  823. for row in results:
  824. work_id = str(row['work_id'])
  825. if work_id not in grouped_data:
  826. grouped_data[work_id] = []
  827. grouped_data[work_id].append({
  828. 'record_date': row['record_date'].strftime('%Y-%m-%d') if row['record_date'] else None,
  829. 'fans_count': row['fans_count'],
  830. 'play_count': row['play_count'],
  831. 'like_count': row['like_count'],
  832. 'comment_count': row['comment_count'],
  833. 'share_count': row['share_count'],
  834. 'collect_count': row['collect_count']
  835. })
  836. return jsonify({
  837. "success": True,
  838. "data": grouped_data
  839. })
  840. except Exception as e:
  841. traceback.print_exc()
  842. return jsonify({"success": False, "error": str(e)}), 500
  843. # ==================== 获取评论列表接口 ====================
  844. @app.route("/comments", methods=["POST"])
  845. def get_comments():
  846. """
  847. 获取作品评论
  848. 请求体:
  849. {
  850. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  851. "cookie": "cookie字符串或JSON",
  852. "work_id": "作品ID",
  853. "cursor": "" # 分页游标(可选)
  854. }
  855. 响应:
  856. {
  857. "success": true,
  858. "platform": "douyin",
  859. "work_id": "xxx",
  860. "comments": [...],
  861. "total": 50,
  862. "has_more": true,
  863. "cursor": "xxx"
  864. }
  865. """
  866. try:
  867. data = request.json
  868. platform = data.get("platform", "").lower()
  869. cookie_str = data.get("cookie", "")
  870. work_id = data.get("work_id", "")
  871. cursor = data.get("cursor", "")
  872. print(f"[Comments] 收到请求: platform={platform}, work_id={work_id}")
  873. if not platform:
  874. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  875. if platform not in PLATFORM_MAP:
  876. return jsonify({
  877. "success": False,
  878. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  879. }), 400
  880. if not cookie_str:
  881. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  882. if not work_id:
  883. return jsonify({"success": False, "error": "缺少 work_id 参数"}), 400
  884. # 获取对应平台的发布器
  885. PublisherClass = get_publisher(platform)
  886. publisher = PublisherClass(headless=HEADLESS_MODE)
  887. # 执行获取评论
  888. result = asyncio.run(publisher.run_get_comments(cookie_str, work_id, cursor))
  889. result_dict = result.to_dict()
  890. # 添加 cursor 到响应
  891. if hasattr(result, '__dict__') and 'cursor' in result.__dict__:
  892. result_dict['cursor'] = result.__dict__['cursor']
  893. return jsonify(result_dict)
  894. except Exception as e:
  895. traceback.print_exc()
  896. return jsonify({"success": False, "error": str(e)}), 500
  897. # ==================== 获取所有作品评论接口 ====================
  898. @app.route("/all_comments", methods=["POST"])
  899. def get_all_comments():
  900. """
  901. 获取所有作品的评论(一次性获取)
  902. 请求体:
  903. {
  904. "platform": "douyin", # douyin | xiaohongshu
  905. "cookie": "cookie字符串或JSON"
  906. }
  907. 响应:
  908. {
  909. "success": true,
  910. "platform": "douyin",
  911. "work_comments": [
  912. {
  913. "work_id": "xxx",
  914. "title": "作品标题",
  915. "cover_url": "封面URL",
  916. "comments": [...]
  917. }
  918. ],
  919. "total": 5
  920. }
  921. """
  922. try:
  923. data = request.json
  924. platform = data.get("platform", "").lower()
  925. cookie_str = data.get("cookie", "")
  926. print(f"[AllComments] 收到请求: platform={platform}")
  927. if not platform:
  928. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  929. if platform not in ['douyin', 'xiaohongshu']:
  930. return jsonify({
  931. "success": False,
  932. "error": f"该接口只支持 douyin 和 xiaohongshu 平台"
  933. }), 400
  934. if not cookie_str:
  935. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  936. # 获取对应平台的发布器
  937. PublisherClass = get_publisher(platform)
  938. publisher = PublisherClass(headless=HEADLESS_MODE)
  939. # 执行获取所有评论
  940. result = asyncio.run(publisher.get_all_comments(cookie_str))
  941. return jsonify(result)
  942. except Exception as e:
  943. traceback.print_exc()
  944. return jsonify({"success": False, "error": str(e)}), 500
  945. # ==================== 登录状态检查接口 ====================
  946. @app.route("/check_login", methods=["POST"])
  947. def check_login():
  948. """
  949. 检查 Cookie 登录状态(通过浏览器访问后台页面检测)
  950. 请求体:
  951. {
  952. "platform": "douyin", # douyin | xiaohongshu | kuaishou | weixin
  953. "cookie": "cookie字符串或JSON"
  954. }
  955. 响应:
  956. {
  957. "success": true,
  958. "valid": true, # Cookie 是否有效
  959. "need_login": false, # 是否需要重新登录
  960. "message": "登录状态有效"
  961. }
  962. """
  963. try:
  964. data = request.json
  965. platform = data.get("platform", "").lower()
  966. cookie_str = data.get("cookie", "")
  967. print(f"[CheckLogin] 收到请求: platform={platform}")
  968. if not platform:
  969. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  970. if platform not in PLATFORM_MAP:
  971. return jsonify({
  972. "success": False,
  973. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  974. }), 400
  975. if not cookie_str:
  976. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  977. # 获取对应平台的发布器
  978. PublisherClass = get_publisher(platform)
  979. publisher = PublisherClass(headless=HEADLESS_MODE)
  980. # 执行登录检查
  981. result = asyncio.run(publisher.check_login_status(cookie_str))
  982. return jsonify(result)
  983. except Exception as e:
  984. traceback.print_exc()
  985. return jsonify({
  986. "success": False,
  987. "valid": False,
  988. "need_login": True,
  989. "error": str(e)
  990. }), 500
  991. # ==================== 获取账号信息接口 ====================
  992. @app.route("/account_info", methods=["POST"])
  993. def get_account_info():
  994. """
  995. 获取账号信息
  996. 请求体:
  997. {
  998. "platform": "baijiahao", # 平台
  999. "cookie": "cookie字符串或JSON"
  1000. }
  1001. 响应:
  1002. {
  1003. "success": true,
  1004. "account_id": "xxx",
  1005. "account_name": "用户名",
  1006. "avatar_url": "头像URL",
  1007. "fans_count": 0,
  1008. "works_count": 0
  1009. }
  1010. """
  1011. try:
  1012. data = request.json
  1013. platform = data.get("platform", "").lower()
  1014. cookie_str = data.get("cookie", "")
  1015. print(f"[AccountInfo] 收到请求: platform={platform}")
  1016. if not platform:
  1017. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  1018. if platform not in PLATFORM_MAP:
  1019. return jsonify({
  1020. "success": False,
  1021. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  1022. }), 400
  1023. if not cookie_str:
  1024. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  1025. # 获取对应平台的发布器
  1026. PublisherClass = get_publisher(platform)
  1027. publisher = PublisherClass(headless=HEADLESS_MODE)
  1028. # 检查是否有 get_account_info 方法
  1029. if hasattr(publisher, 'get_account_info'):
  1030. result = asyncio.run(publisher.get_account_info(cookie_str))
  1031. return jsonify(result)
  1032. else:
  1033. return jsonify({
  1034. "success": False,
  1035. "error": f"平台 {platform} 不支持获取账号信息"
  1036. }), 400
  1037. except Exception as e:
  1038. traceback.print_exc()
  1039. return jsonify({"success": False, "error": str(e)}), 500
  1040. # ==================== 健康检查 ====================
  1041. @app.route("/health", methods=["GET"])
  1042. def health_check():
  1043. """健康检查"""
  1044. # 检查 xhs SDK 是否可用
  1045. xhs_available = False
  1046. try:
  1047. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  1048. xhs_available = XHS_SDK_AVAILABLE
  1049. except:
  1050. pass
  1051. return jsonify({
  1052. "status": "ok",
  1053. "xhs_sdk": xhs_available,
  1054. "supported_platforms": list(PLATFORM_MAP.keys()),
  1055. "headless_mode": HEADLESS_MODE
  1056. })
  1057. @app.route("/", methods=["GET"])
  1058. def index():
  1059. """首页"""
  1060. return jsonify({
  1061. "name": "多平台视频发布服务",
  1062. "version": "1.2.0",
  1063. "endpoints": {
  1064. "GET /": "服务信息",
  1065. "GET /health": "健康检查",
  1066. "POST /publish": "发布视频",
  1067. "POST /publish/batch": "批量发布",
  1068. "POST /works": "获取作品列表",
  1069. "POST /comments": "获取作品评论",
  1070. "POST /all_comments": "获取所有作品评论",
  1071. "POST /work_day_statistics": "保存作品每日统计数据",
  1072. "POST /work_day_statistics/batch": "获取作品历史统计数据",
  1073. "POST /check_cookie": "检查 Cookie",
  1074. "POST /sign": "小红书签名"
  1075. },
  1076. "supported_platforms": list(PLATFORM_MAP.keys())
  1077. })
  1078. # ==================== 命令行启动 ====================
  1079. def main():
  1080. parser = argparse.ArgumentParser(description='多平台视频发布服务')
  1081. parser.add_argument('--port', type=int, default=5005, help='服务端口 (默认: 5005)')
  1082. parser.add_argument('--host', type=str, default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
  1083. parser.add_argument('--headless', type=str, default='true', help='是否无头模式 (默认: true)')
  1084. parser.add_argument('--debug', action='store_true', help='调试模式')
  1085. args = parser.parse_args()
  1086. global HEADLESS_MODE
  1087. HEADLESS_MODE = args.headless.lower() == 'true'
  1088. # 检查 xhs SDK
  1089. xhs_status = "未安装"
  1090. try:
  1091. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  1092. xhs_status = "已安装" if XHS_SDK_AVAILABLE else "未安装"
  1093. except:
  1094. pass
  1095. print("=" * 60)
  1096. print("多平台视频发布服务")
  1097. print("=" * 60)
  1098. print(f"XHS SDK: {xhs_status}")
  1099. print(f"Headless 模式: {HEADLESS_MODE}")
  1100. print(f"支持平台: {', '.join(PLATFORM_MAP.keys())}")
  1101. print("=" * 60)
  1102. print(f"启动服务: http://{args.host}:{args.port}")
  1103. print("=" * 60)
  1104. app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
  1105. if __name__ == '__main__':
  1106. main()