app.py 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385
  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. "screenshot_base64": result.screenshot_base64,
  236. "page_url": result.page_url,
  237. "status": result.status
  238. }
  239. # 如果需要验证码,打印明确的日志
  240. if result.need_captcha:
  241. print(f"[Publish] 需要验证码: type={result.captcha_type}")
  242. return jsonify(response_data)
  243. except Exception as e:
  244. traceback.print_exc()
  245. return jsonify({"success": False, "error": str(e)}), 500
  246. # ==================== AI 辅助发布接口 ====================
  247. # 存储活跃的发布会话
  248. active_publish_sessions = {}
  249. @app.route("/publish/ai-assisted", methods=["POST"])
  250. def publish_ai_assisted():
  251. """
  252. AI 辅助发布接口
  253. 与普通发布接口的区别:
  254. 1. 发布过程中会返回截图供 AI 分析
  255. 2. 如果检测到需要验证码,返回截图和状态,等待外部处理
  256. 3. 支持继续发布(输入验证码后)
  257. 请求体:
  258. {
  259. "platform": "douyin",
  260. "cookie": "cookie字符串",
  261. "title": "视频标题",
  262. "video_path": "视频文件路径",
  263. ...
  264. "return_screenshot": true // 是否返回截图
  265. }
  266. 响应:
  267. {
  268. "success": true/false,
  269. "status": "success|failed|need_captcha|processing",
  270. "screenshot_base64": "...", // 当前页面截图
  271. "page_url": "...",
  272. ...
  273. }
  274. """
  275. try:
  276. data = request.json
  277. # 获取参数
  278. platform = data.get("platform", "").lower()
  279. cookie_str = data.get("cookie", "")
  280. title = data.get("title", "")
  281. description = data.get("description", "")
  282. video_path = data.get("video_path", "")
  283. cover_path = data.get("cover_path")
  284. tags = data.get("tags", [])
  285. post_time = data.get("post_time")
  286. location = data.get("location", "重庆市")
  287. return_screenshot = data.get("return_screenshot", True)
  288. # 参数验证
  289. if not platform:
  290. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  291. if platform not in PLATFORM_MAP:
  292. return jsonify({"success": False, "error": f"不支持的平台: {platform}"}), 400
  293. if not cookie_str:
  294. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  295. if not title:
  296. return jsonify({"success": False, "error": "缺少 title 参数"}), 400
  297. if not video_path or not os.path.exists(video_path):
  298. return jsonify({"success": False, "error": f"视频文件不存在: {video_path}"}), 400
  299. # 解析发布时间
  300. publish_date = parse_datetime(post_time) if post_time else None
  301. # 创建发布参数
  302. params = PublishParams(
  303. title=title,
  304. video_path=video_path,
  305. description=description,
  306. cover_path=cover_path,
  307. tags=tags,
  308. publish_date=publish_date,
  309. location=location
  310. )
  311. print("=" * 60)
  312. print(f"[AI Publish] 平台: {platform}")
  313. print(f"[AI Publish] 标题: {title}")
  314. print(f"[AI Publish] 视频: {video_path}")
  315. print("=" * 60)
  316. # 获取对应平台的发布器
  317. PublisherClass = get_publisher(platform)
  318. publisher = PublisherClass(headless=HEADLESS_MODE)
  319. # 执行发布
  320. result = asyncio.run(publisher.run(cookie_str, params))
  321. response_data = {
  322. "success": result.success,
  323. "platform": result.platform,
  324. "video_id": result.video_id,
  325. "video_url": result.video_url,
  326. "message": result.message,
  327. "error": result.error,
  328. "need_captcha": result.need_captcha,
  329. "captcha_type": result.captcha_type,
  330. "status": result.status or ("success" if result.success else "failed"),
  331. "page_url": result.page_url
  332. }
  333. # 如果请求返回截图
  334. if return_screenshot and result.screenshot_base64:
  335. response_data["screenshot_base64"] = result.screenshot_base64
  336. return jsonify(response_data)
  337. except Exception as e:
  338. traceback.print_exc()
  339. return jsonify({"success": False, "error": str(e), "status": "error"}), 500
  340. # ==================== 批量发布接口 ====================
  341. @app.route("/publish/batch", methods=["POST"])
  342. def publish_batch():
  343. """
  344. 批量发布接口 - 发布到多个平台
  345. 请求体:
  346. {
  347. "platforms": ["douyin", "xiaohongshu"],
  348. "cookies": {
  349. "douyin": "cookie字符串",
  350. "xiaohongshu": "cookie字符串"
  351. },
  352. "title": "视频标题",
  353. "video_path": "视频文件绝对路径",
  354. ...
  355. }
  356. """
  357. try:
  358. data = request.json
  359. platforms = data.get("platforms", [])
  360. cookies = data.get("cookies", {})
  361. if not platforms:
  362. return jsonify({"success": False, "error": "缺少 platforms 参数"}), 400
  363. results = []
  364. for platform in platforms:
  365. platform = platform.lower()
  366. cookie_str = cookies.get(platform, "")
  367. if not cookie_str:
  368. results.append({
  369. "platform": platform,
  370. "success": False,
  371. "error": f"缺少 {platform} 的 cookie"
  372. })
  373. continue
  374. try:
  375. # 创建参数
  376. params = PublishParams(
  377. title=data.get("title", ""),
  378. video_path=data.get("video_path", ""),
  379. description=data.get("description", ""),
  380. cover_path=data.get("cover_path"),
  381. tags=data.get("tags", []),
  382. publish_date=parse_datetime(data.get("post_time")),
  383. location=data.get("location", "重庆市")
  384. )
  385. # 发布
  386. PublisherClass = get_publisher(platform)
  387. publisher = PublisherClass(headless=HEADLESS_MODE)
  388. result = asyncio.run(publisher.run(cookie_str, params))
  389. results.append({
  390. "platform": result.platform,
  391. "success": result.success,
  392. "video_id": result.video_id,
  393. "message": result.message,
  394. "error": result.error
  395. })
  396. except Exception as e:
  397. results.append({
  398. "platform": platform,
  399. "success": False,
  400. "error": str(e)
  401. })
  402. # 统计成功/失败数量
  403. success_count = sum(1 for r in results if r.get("success"))
  404. return jsonify({
  405. "success": success_count > 0,
  406. "total": len(platforms),
  407. "success_count": success_count,
  408. "fail_count": len(platforms) - success_count,
  409. "results": results
  410. })
  411. except Exception as e:
  412. traceback.print_exc()
  413. return jsonify({"success": False, "error": str(e)}), 500
  414. # ==================== Cookie 验证接口 ====================
  415. @app.route("/check_cookie", methods=["POST"])
  416. def check_cookie():
  417. """检查 cookie 是否有效"""
  418. try:
  419. data = request.json
  420. platform = data.get("platform", "").lower()
  421. cookie_str = data.get("cookie", "")
  422. if not cookie_str:
  423. return jsonify({"valid": False, "error": "缺少 cookie 参数"}), 400
  424. # 目前只支持小红书的 cookie 验证
  425. if platform == "xiaohongshu":
  426. try:
  427. from platforms.xiaohongshu import XiaohongshuPublisher, XHS_SDK_AVAILABLE
  428. if XHS_SDK_AVAILABLE:
  429. from xhs import XhsClient
  430. publisher = XiaohongshuPublisher()
  431. xhs_client = XhsClient(cookie_str, sign=publisher.sign_sync)
  432. info = xhs_client.get_self_info()
  433. if info:
  434. return jsonify({
  435. "valid": True,
  436. "user_info": {
  437. "user_id": info.get("user_id"),
  438. "nickname": info.get("nickname"),
  439. "avatar": info.get("images")
  440. }
  441. })
  442. except Exception as e:
  443. return jsonify({"valid": False, "error": str(e)})
  444. # 其他平台返回格式正确但未验证
  445. return jsonify({
  446. "valid": True,
  447. "message": "Cookie 格式正确,但未进行在线验证"
  448. })
  449. except Exception as e:
  450. traceback.print_exc()
  451. return jsonify({"valid": False, "error": str(e)})
  452. # ==================== 获取作品列表接口 ====================
  453. @app.route("/works", methods=["POST"])
  454. def get_works():
  455. """
  456. 获取作品列表
  457. 请求体:
  458. {
  459. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  460. "cookie": "cookie字符串或JSON",
  461. "page": 0, # 页码(从0开始,可选,默认0)
  462. "page_size": 20 # 每页数量(可选,默认20)
  463. }
  464. 响应:
  465. {
  466. "success": true,
  467. "platform": "douyin",
  468. "works": [...],
  469. "total": 100,
  470. "has_more": true
  471. }
  472. """
  473. try:
  474. data = request.json
  475. platform = data.get("platform", "").lower()
  476. cookie_str = data.get("cookie", "")
  477. page = data.get("page", 0)
  478. page_size = data.get("page_size", 20)
  479. print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}")
  480. if not platform:
  481. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  482. if platform not in PLATFORM_MAP:
  483. return jsonify({
  484. "success": False,
  485. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  486. }), 400
  487. if not cookie_str:
  488. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  489. # 获取对应平台的发布器
  490. PublisherClass = get_publisher(platform)
  491. publisher = PublisherClass(headless=HEADLESS_MODE)
  492. # 执行获取作品
  493. result = asyncio.run(publisher.run_get_works(cookie_str, page, page_size))
  494. return jsonify(result.to_dict())
  495. except Exception as e:
  496. traceback.print_exc()
  497. return jsonify({"success": False, "error": str(e)}), 500
  498. # ==================== 保存作品日统计数据接口 ====================
  499. @app.route("/work_day_statistics", methods=["POST"])
  500. def save_work_day_statistics():
  501. """
  502. 保存作品每日统计数据
  503. 当天的数据走更新流,日期变化走新增流
  504. 请求体:
  505. {
  506. "statistics": [
  507. {
  508. "work_id": 1,
  509. "fans_count": 1000,
  510. "play_count": 5000,
  511. "like_count": 200,
  512. "comment_count": 50,
  513. "share_count": 30,
  514. "collect_count": 100
  515. },
  516. ...
  517. ]
  518. }
  519. 响应:
  520. {
  521. "success": true,
  522. "inserted": 5,
  523. "updated": 3,
  524. "message": "保存成功"
  525. }
  526. """
  527. print("=" * 60, flush=True)
  528. print("[DEBUG] ===== 进入 save_work_day_statistics 方法 =====", flush=True)
  529. print(f"[DEBUG] 请求方法: {request.method}", flush=True)
  530. print(f"[DEBUG] 请求数据: {request.json}", flush=True)
  531. print("=" * 60, flush=True)
  532. try:
  533. data = request.json
  534. statistics_list = data.get("statistics", [])
  535. if not statistics_list:
  536. return jsonify({"success": False, "error": "缺少 statistics 参数"}), 400
  537. today = date.today()
  538. inserted_count = 0
  539. updated_count = 0
  540. print(f"[WorkDayStatistics] 收到请求: {len(statistics_list)} 条统计数据")
  541. conn = get_db_connection()
  542. try:
  543. with conn.cursor() as cursor:
  544. for stat in statistics_list:
  545. work_id = stat.get("work_id")
  546. if not work_id:
  547. continue
  548. fans_count = stat.get("fans_count", 0)
  549. play_count = stat.get("play_count", 0)
  550. like_count = stat.get("like_count", 0)
  551. comment_count = stat.get("comment_count", 0)
  552. share_count = stat.get("share_count", 0)
  553. collect_count = stat.get("collect_count", 0)
  554. # 检查当天是否已有记录
  555. cursor.execute(
  556. "SELECT id FROM work_day_statistics WHERE work_id = %s AND record_date = %s",
  557. (work_id, today)
  558. )
  559. existing = cursor.fetchone()
  560. if existing:
  561. # 更新已有记录
  562. cursor.execute(
  563. """UPDATE work_day_statistics
  564. SET fans_count = %s, play_count = %s, like_count = %s,
  565. comment_count = %s, share_count = %s, collect_count = %s,
  566. updated_at = NOW()
  567. WHERE id = %s""",
  568. (fans_count, play_count, like_count, comment_count,
  569. share_count, collect_count, existing['id'])
  570. )
  571. updated_count += 1
  572. else:
  573. # 插入新记录
  574. cursor.execute(
  575. """INSERT INTO work_day_statistics
  576. (work_id, record_date, fans_count, play_count, like_count,
  577. comment_count, share_count, collect_count, created_at, updated_at)
  578. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())""",
  579. (work_id, today, fans_count, play_count, like_count,
  580. comment_count, share_count, collect_count)
  581. )
  582. inserted_count += 1
  583. conn.commit()
  584. finally:
  585. conn.close()
  586. print(f"[WorkDayStatistics] 完成: 新增 {inserted_count} 条, 更新 {updated_count} 条")
  587. return jsonify({
  588. "success": True,
  589. "inserted": inserted_count,
  590. "updated": updated_count,
  591. "message": f"保存成功: 新增 {inserted_count} 条, 更新 {updated_count} 条"
  592. })
  593. except Exception as e:
  594. traceback.print_exc()
  595. return jsonify({"success": False, "error": str(e)}), 500
  596. @app.route("/work_day_statistics/trend", methods=["GET"])
  597. def get_statistics_trend():
  598. """
  599. 获取数据趋势(用于 Dashboard 数据看板)
  600. 查询参数:
  601. user_id: 用户ID (必填)
  602. days: 天数 (可选,默认7天,最大30天)
  603. account_id: 账号ID (可选,不填则查询所有账号)
  604. 响应:
  605. {
  606. "success": true,
  607. "data": {
  608. "dates": ["01-16", "01-17", "01-18", ...],
  609. "fans": [100, 120, 130, ...],
  610. "views": [1000, 1200, 1500, ...],
  611. "likes": [50, 60, 70, ...],
  612. "comments": [10, 12, 15, ...],
  613. "shares": [5, 6, 8, ...],
  614. "collects": [20, 25, 30, ...]
  615. }
  616. }
  617. """
  618. try:
  619. user_id = request.args.get("user_id")
  620. days = min(int(request.args.get("days", 7)), 30) # 最大30天
  621. account_id = request.args.get("account_id")
  622. if not user_id:
  623. return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
  624. conn = get_db_connection()
  625. try:
  626. with conn.cursor() as cursor:
  627. # 构建查询:关联 works 表获取用户的作品,然后汇总统计数据
  628. # 注意:粉丝数是账号级别的数据,每个账号每天只取一个值(使用 MAX)
  629. # 其他指标(播放、点赞等)是作品级别的数据,需要累加
  630. sql = """
  631. SELECT
  632. record_date,
  633. SUM(account_fans) as total_fans,
  634. SUM(account_views) as total_views,
  635. SUM(account_likes) as total_likes,
  636. SUM(account_comments) as total_comments,
  637. SUM(account_shares) as total_shares,
  638. SUM(account_collects) as total_collects
  639. FROM (
  640. SELECT
  641. wds.record_date,
  642. w.accountId,
  643. MAX(wds.fans_count) as account_fans,
  644. SUM(wds.play_count) as account_views,
  645. SUM(wds.like_count) as account_likes,
  646. SUM(wds.comment_count) as account_comments,
  647. SUM(wds.share_count) as account_shares,
  648. SUM(wds.collect_count) as account_collects
  649. FROM work_day_statistics wds
  650. INNER JOIN works w ON wds.work_id = w.id
  651. WHERE w.userId = %s
  652. AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
  653. """
  654. params = [user_id, days]
  655. if account_id:
  656. sql += " AND w.accountId = %s"
  657. params.append(account_id)
  658. sql += """
  659. GROUP BY wds.record_date, w.accountId
  660. ) as account_stats
  661. GROUP BY record_date
  662. ORDER BY record_date ASC
  663. """
  664. cursor.execute(sql, params)
  665. results = cursor.fetchall()
  666. # 构建响应数据
  667. dates = []
  668. fans = []
  669. views = []
  670. likes = []
  671. comments = []
  672. shares = []
  673. collects = []
  674. for row in results:
  675. # 格式化日期为 "MM-DD" 格式
  676. record_date = row['record_date']
  677. if isinstance(record_date, str):
  678. dates.append(record_date[5:10]) # "2026-01-16" -> "01-16"
  679. else:
  680. dates.append(record_date.strftime("%m-%d"))
  681. # 确保返回整数类型
  682. fans.append(int(row['total_fans'] or 0))
  683. views.append(int(row['total_views'] or 0))
  684. likes.append(int(row['total_likes'] or 0))
  685. comments.append(int(row['total_comments'] or 0))
  686. shares.append(int(row['total_shares'] or 0))
  687. collects.append(int(row['total_collects'] or 0))
  688. # 如果没有数据,生成空的日期范围
  689. if not dates:
  690. from datetime import timedelta
  691. today = date.today()
  692. for i in range(days, 0, -1):
  693. d = today - timedelta(days=i-1)
  694. dates.append(d.strftime("%m-%d"))
  695. fans.append(0)
  696. views.append(0)
  697. likes.append(0)
  698. comments.append(0)
  699. shares.append(0)
  700. collects.append(0)
  701. return jsonify({
  702. "success": True,
  703. "data": {
  704. "dates": dates,
  705. "fans": fans,
  706. "views": views,
  707. "likes": likes,
  708. "comments": comments,
  709. "shares": shares,
  710. "collects": collects
  711. }
  712. })
  713. finally:
  714. conn.close()
  715. except Exception as e:
  716. traceback.print_exc()
  717. return jsonify({"success": False, "error": str(e)}), 500
  718. @app.route("/work_day_statistics/platforms", methods=["GET"])
  719. def get_statistics_by_platform():
  720. """
  721. 按平台分组获取统计数据(用于数据分析页面的平台对比)
  722. 数据来源:
  723. - 粉丝数:从 platform_accounts 表获取(账号级别数据)
  724. - 播放量/点赞/评论/收藏:从 work_day_statistics 表按平台汇总
  725. - 粉丝增量:通过比较区间内最早和最新的粉丝数计算
  726. 查询参数:
  727. user_id: 用户ID (必填)
  728. days: 天数 (可选,默认30天,最大30天)
  729. 响应:
  730. {
  731. "success": true,
  732. "data": [
  733. {
  734. "platform": "douyin",
  735. "fansCount": 1000,
  736. "fansIncrease": 50,
  737. "viewsCount": 5000,
  738. "likesCount": 200,
  739. "commentsCount": 30,
  740. "collectsCount": 100
  741. },
  742. ...
  743. ]
  744. }
  745. """
  746. try:
  747. user_id = request.args.get("user_id")
  748. days = min(int(request.args.get("days", 30)), 30)
  749. if not user_id:
  750. return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
  751. conn = get_db_connection()
  752. try:
  753. with conn.cursor() as cursor:
  754. # 简化查询:按平台分组获取统计数据
  755. # 1. 从 platform_accounts 获取当前粉丝数(注意:字段名是下划线命名 fans_count, user_id)
  756. # 2. 从 work_day_statistics 获取播放量等累计数据
  757. sql = """
  758. SELECT
  759. pa.platform,
  760. pa.fans_count as current_fans,
  761. COALESCE(stats.total_views, 0) as viewsCount,
  762. COALESCE(stats.total_likes, 0) as likesCount,
  763. COALESCE(stats.total_comments, 0) as commentsCount,
  764. COALESCE(stats.total_collects, 0) as collectsCount,
  765. COALESCE(fans_change.earliest_fans, pa.fans_count) as earliest_fans
  766. FROM platform_accounts pa
  767. LEFT JOIN (
  768. -- 获取区间内的累计数据(按账号汇总)
  769. SELECT
  770. w.accountId,
  771. SUM(wds.play_count) as total_views,
  772. SUM(wds.like_count) as total_likes,
  773. SUM(wds.comment_count) as total_comments,
  774. SUM(wds.collect_count) as total_collects
  775. FROM work_day_statistics wds
  776. INNER JOIN works w ON wds.work_id = w.id
  777. WHERE w.userId = %s
  778. AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
  779. GROUP BY w.accountId
  780. ) stats ON pa.id = stats.accountId
  781. LEFT JOIN (
  782. -- 获取区间内最早一天的粉丝数
  783. SELECT
  784. w.accountId,
  785. MAX(wds.fans_count) as earliest_fans
  786. FROM work_day_statistics wds
  787. INNER JOIN works w ON wds.work_id = w.id
  788. WHERE w.userId = %s
  789. AND wds.record_date = (
  790. SELECT MIN(wds2.record_date)
  791. FROM work_day_statistics wds2
  792. INNER JOIN works w2 ON wds2.work_id = w2.id
  793. WHERE w2.userId = %s
  794. AND wds2.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
  795. )
  796. GROUP BY w.accountId
  797. ) fans_change ON pa.id = fans_change.accountId
  798. WHERE pa.user_id = %s
  799. ORDER BY current_fans DESC
  800. """
  801. cursor.execute(sql, [user_id, days, user_id, user_id, days, user_id])
  802. results = cursor.fetchall()
  803. # 构建响应数据
  804. platform_data = []
  805. for row in results:
  806. current_fans = int(row['current_fans'] or 0)
  807. earliest_fans = int(row['earliest_fans'] or current_fans)
  808. fans_increase = current_fans - earliest_fans
  809. platform_data.append({
  810. "platform": row['platform'],
  811. "fansCount": current_fans,
  812. "fansIncrease": fans_increase,
  813. "viewsCount": int(row['viewsCount'] or 0),
  814. "likesCount": int(row['likesCount'] or 0),
  815. "commentsCount": int(row['commentsCount'] or 0),
  816. "collectsCount": int(row['collectsCount'] or 0),
  817. })
  818. print(f"[PlatformStats] 返回 {len(platform_data)} 个平台的数据")
  819. return jsonify({
  820. "success": True,
  821. "data": platform_data
  822. })
  823. finally:
  824. conn.close()
  825. except Exception as e:
  826. traceback.print_exc()
  827. return jsonify({"success": False, "error": str(e)}), 500
  828. @app.route("/work_day_statistics/batch", methods=["POST"])
  829. def get_work_statistics_history():
  830. """
  831. 批量获取作品的历史统计数据
  832. 请求体:
  833. {
  834. "work_ids": [1, 2, 3],
  835. "start_date": "2025-01-01", # 可选
  836. "end_date": "2025-01-21" # 可选
  837. }
  838. 响应:
  839. {
  840. "success": true,
  841. "data": {
  842. "1": [
  843. {"record_date": "2025-01-20", "play_count": 100, ...},
  844. {"record_date": "2025-01-21", "play_count": 150, ...}
  845. ],
  846. ...
  847. }
  848. }
  849. """
  850. try:
  851. data = request.json
  852. work_ids = data.get("work_ids", [])
  853. start_date = data.get("start_date")
  854. end_date = data.get("end_date")
  855. if not work_ids:
  856. return jsonify({"success": False, "error": "缺少 work_ids 参数"}), 400
  857. conn = get_db_connection()
  858. try:
  859. with conn.cursor() as cursor:
  860. # 构建查询
  861. placeholders = ', '.join(['%s'] * len(work_ids))
  862. sql = f"""SELECT work_id, record_date, fans_count, play_count, like_count,
  863. comment_count, share_count, collect_count
  864. FROM work_day_statistics
  865. WHERE work_id IN ({placeholders})"""
  866. params = list(work_ids)
  867. if start_date:
  868. sql += " AND record_date >= %s"
  869. params.append(start_date)
  870. if end_date:
  871. sql += " AND record_date <= %s"
  872. params.append(end_date)
  873. sql += " ORDER BY work_id, record_date"
  874. cursor.execute(sql, params)
  875. results = cursor.fetchall()
  876. finally:
  877. conn.close()
  878. # 按 work_id 分组
  879. grouped_data = {}
  880. for row in results:
  881. work_id = str(row['work_id'])
  882. if work_id not in grouped_data:
  883. grouped_data[work_id] = []
  884. grouped_data[work_id].append({
  885. 'record_date': row['record_date'].strftime('%Y-%m-%d') if row['record_date'] else None,
  886. 'fans_count': row['fans_count'],
  887. 'play_count': row['play_count'],
  888. 'like_count': row['like_count'],
  889. 'comment_count': row['comment_count'],
  890. 'share_count': row['share_count'],
  891. 'collect_count': row['collect_count']
  892. })
  893. return jsonify({
  894. "success": True,
  895. "data": grouped_data
  896. })
  897. except Exception as e:
  898. traceback.print_exc()
  899. return jsonify({"success": False, "error": str(e)}), 500
  900. # ==================== 获取评论列表接口 ====================
  901. @app.route("/comments", methods=["POST"])
  902. def get_comments():
  903. """
  904. 获取作品评论
  905. 请求体:
  906. {
  907. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  908. "cookie": "cookie字符串或JSON",
  909. "work_id": "作品ID",
  910. "cursor": "" # 分页游标(可选)
  911. }
  912. 响应:
  913. {
  914. "success": true,
  915. "platform": "douyin",
  916. "work_id": "xxx",
  917. "comments": [...],
  918. "total": 50,
  919. "has_more": true,
  920. "cursor": "xxx"
  921. }
  922. """
  923. try:
  924. data = request.json
  925. platform = data.get("platform", "").lower()
  926. cookie_str = data.get("cookie", "")
  927. work_id = data.get("work_id", "")
  928. cursor = data.get("cursor", "")
  929. print(f"[Comments] 收到请求: platform={platform}, work_id={work_id}")
  930. if not platform:
  931. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  932. if platform not in PLATFORM_MAP:
  933. return jsonify({
  934. "success": False,
  935. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  936. }), 400
  937. if not cookie_str:
  938. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  939. if not work_id:
  940. return jsonify({"success": False, "error": "缺少 work_id 参数"}), 400
  941. # 获取对应平台的发布器
  942. PublisherClass = get_publisher(platform)
  943. publisher = PublisherClass(headless=HEADLESS_MODE)
  944. # 执行获取评论
  945. result = asyncio.run(publisher.run_get_comments(cookie_str, work_id, cursor))
  946. result_dict = result.to_dict()
  947. # 添加 cursor 到响应
  948. if hasattr(result, '__dict__') and 'cursor' in result.__dict__:
  949. result_dict['cursor'] = result.__dict__['cursor']
  950. return jsonify(result_dict)
  951. except Exception as e:
  952. traceback.print_exc()
  953. return jsonify({"success": False, "error": str(e)}), 500
  954. # ==================== 获取所有作品评论接口 ====================
  955. @app.route("/all_comments", methods=["POST"])
  956. def get_all_comments():
  957. """
  958. 获取所有作品的评论(一次性获取)
  959. 请求体:
  960. {
  961. "platform": "douyin", # douyin | xiaohongshu
  962. "cookie": "cookie字符串或JSON"
  963. }
  964. 响应:
  965. {
  966. "success": true,
  967. "platform": "douyin",
  968. "work_comments": [
  969. {
  970. "work_id": "xxx",
  971. "title": "作品标题",
  972. "cover_url": "封面URL",
  973. "comments": [...]
  974. }
  975. ],
  976. "total": 5
  977. }
  978. """
  979. try:
  980. data = request.json
  981. platform = data.get("platform", "").lower()
  982. cookie_str = data.get("cookie", "")
  983. print(f"[AllComments] 收到请求: platform={platform}")
  984. if not platform:
  985. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  986. if platform not in ['douyin', 'xiaohongshu']:
  987. return jsonify({
  988. "success": False,
  989. "error": f"该接口只支持 douyin 和 xiaohongshu 平台"
  990. }), 400
  991. if not cookie_str:
  992. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  993. # 获取对应平台的发布器
  994. PublisherClass = get_publisher(platform)
  995. publisher = PublisherClass(headless=HEADLESS_MODE)
  996. # 执行获取所有评论
  997. result = asyncio.run(publisher.get_all_comments(cookie_str))
  998. return jsonify(result)
  999. except Exception as e:
  1000. traceback.print_exc()
  1001. return jsonify({"success": False, "error": str(e)}), 500
  1002. # ==================== 登录状态检查接口 ====================
  1003. @app.route("/check_login", methods=["POST"])
  1004. def check_login():
  1005. """
  1006. 检查 Cookie 登录状态(通过浏览器访问后台页面检测)
  1007. 请求体:
  1008. {
  1009. "platform": "douyin", # douyin | xiaohongshu | kuaishou | weixin
  1010. "cookie": "cookie字符串或JSON"
  1011. }
  1012. 响应:
  1013. {
  1014. "success": true,
  1015. "valid": true, # Cookie 是否有效
  1016. "need_login": false, # 是否需要重新登录
  1017. "message": "登录状态有效"
  1018. }
  1019. """
  1020. try:
  1021. data = request.json
  1022. platform = data.get("platform", "").lower()
  1023. cookie_str = data.get("cookie", "")
  1024. print(f"[CheckLogin] 收到请求: platform={platform}")
  1025. if not platform:
  1026. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  1027. if platform not in PLATFORM_MAP:
  1028. return jsonify({
  1029. "success": False,
  1030. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  1031. }), 400
  1032. if not cookie_str:
  1033. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  1034. # 获取对应平台的发布器
  1035. PublisherClass = get_publisher(platform)
  1036. publisher = PublisherClass(headless=HEADLESS_MODE)
  1037. # 执行登录检查
  1038. result = asyncio.run(publisher.check_login_status(cookie_str))
  1039. return jsonify(result)
  1040. except Exception as e:
  1041. traceback.print_exc()
  1042. return jsonify({
  1043. "success": False,
  1044. "valid": False,
  1045. "need_login": True,
  1046. "error": str(e)
  1047. }), 500
  1048. # ==================== 获取账号信息接口 ====================
  1049. @app.route("/account_info", methods=["POST"])
  1050. def get_account_info():
  1051. """
  1052. 获取账号信息
  1053. 请求体:
  1054. {
  1055. "platform": "baijiahao", # 平台
  1056. "cookie": "cookie字符串或JSON"
  1057. }
  1058. 响应:
  1059. {
  1060. "success": true,
  1061. "account_id": "xxx",
  1062. "account_name": "用户名",
  1063. "avatar_url": "头像URL",
  1064. "fans_count": 0,
  1065. "works_count": 0
  1066. }
  1067. """
  1068. try:
  1069. data = request.json
  1070. platform = data.get("platform", "").lower()
  1071. cookie_str = data.get("cookie", "")
  1072. print(f"[AccountInfo] 收到请求: platform={platform}")
  1073. if not platform:
  1074. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  1075. if platform not in PLATFORM_MAP:
  1076. return jsonify({
  1077. "success": False,
  1078. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  1079. }), 400
  1080. if not cookie_str:
  1081. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  1082. # 获取对应平台的发布器
  1083. PublisherClass = get_publisher(platform)
  1084. publisher = PublisherClass(headless=HEADLESS_MODE)
  1085. # 检查是否有 get_account_info 方法
  1086. if hasattr(publisher, 'get_account_info'):
  1087. result = asyncio.run(publisher.get_account_info(cookie_str))
  1088. return jsonify(result)
  1089. else:
  1090. return jsonify({
  1091. "success": False,
  1092. "error": f"平台 {platform} 不支持获取账号信息"
  1093. }), 400
  1094. except Exception as e:
  1095. traceback.print_exc()
  1096. return jsonify({"success": False, "error": str(e)}), 500
  1097. # ==================== 健康检查 ====================
  1098. @app.route("/health", methods=["GET"])
  1099. def health_check():
  1100. """健康检查"""
  1101. # 检查 xhs SDK 是否可用
  1102. xhs_available = False
  1103. try:
  1104. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  1105. xhs_available = XHS_SDK_AVAILABLE
  1106. except:
  1107. pass
  1108. return jsonify({
  1109. "status": "ok",
  1110. "xhs_sdk": xhs_available,
  1111. "supported_platforms": list(PLATFORM_MAP.keys()),
  1112. "headless_mode": HEADLESS_MODE
  1113. })
  1114. @app.route("/", methods=["GET"])
  1115. def index():
  1116. """首页"""
  1117. return jsonify({
  1118. "name": "多平台视频发布服务",
  1119. "version": "1.2.0",
  1120. "endpoints": {
  1121. "GET /": "服务信息",
  1122. "GET /health": "健康检查",
  1123. "POST /publish": "发布视频",
  1124. "POST /publish/batch": "批量发布",
  1125. "POST /works": "获取作品列表",
  1126. "POST /comments": "获取作品评论",
  1127. "POST /all_comments": "获取所有作品评论",
  1128. "POST /work_day_statistics": "保存作品每日统计数据",
  1129. "POST /work_day_statistics/batch": "获取作品历史统计数据",
  1130. "POST /check_cookie": "检查 Cookie",
  1131. "POST /sign": "小红书签名"
  1132. },
  1133. "supported_platforms": list(PLATFORM_MAP.keys())
  1134. })
  1135. # ==================== 命令行启动 ====================
  1136. def main():
  1137. parser = argparse.ArgumentParser(description='多平台视频发布服务')
  1138. parser.add_argument('--port', type=int, default=5005, help='服务端口 (默认: 5005)')
  1139. parser.add_argument('--host', type=str, default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
  1140. parser.add_argument('--headless', type=str, default='true', help='是否无头模式 (默认: true)')
  1141. parser.add_argument('--debug', action='store_true', help='调试模式')
  1142. args = parser.parse_args()
  1143. global HEADLESS_MODE
  1144. HEADLESS_MODE = args.headless.lower() == 'true'
  1145. # 检查 xhs SDK
  1146. xhs_status = "未安装"
  1147. try:
  1148. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  1149. xhs_status = "已安装" if XHS_SDK_AVAILABLE else "未安装"
  1150. except:
  1151. pass
  1152. print("=" * 60)
  1153. print("多平台视频发布服务")
  1154. print("=" * 60)
  1155. print(f"XHS SDK: {xhs_status}")
  1156. print(f"Headless 模式: {HEADLESS_MODE}")
  1157. print(f"支持平台: {', '.join(PLATFORM_MAP.keys())}")
  1158. print("=" * 60)
  1159. print(f"启动服务: http://{args.host}:{args.port}")
  1160. print("=" * 60)
  1161. app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
  1162. if __name__ == '__main__':
  1163. main()