app.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  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
  18. from pathlib import Path
  19. # 确保当前目录在 Python 路径中
  20. CURRENT_DIR = Path(__file__).parent.resolve()
  21. if str(CURRENT_DIR) not in sys.path:
  22. sys.path.insert(0, str(CURRENT_DIR))
  23. from flask import Flask, request, jsonify
  24. from flask_cors import CORS
  25. from platforms import get_publisher, PLATFORM_MAP
  26. from platforms.base import PublishParams
  27. def parse_datetime(date_str: str):
  28. """解析日期时间字符串"""
  29. if not date_str:
  30. return None
  31. formats = [
  32. "%Y-%m-%d %H:%M:%S",
  33. "%Y-%m-%d %H:%M",
  34. "%Y/%m/%d %H:%M:%S",
  35. "%Y/%m/%d %H:%M",
  36. "%Y-%m-%dT%H:%M:%S",
  37. "%Y-%m-%dT%H:%M:%SZ",
  38. ]
  39. for fmt in formats:
  40. try:
  41. return datetime.strptime(date_str, fmt)
  42. except ValueError:
  43. continue
  44. return None
  45. def validate_video_file(video_path: str) -> bool:
  46. """验证视频文件是否有效"""
  47. if not video_path:
  48. return False
  49. if not os.path.exists(video_path):
  50. return False
  51. if not os.path.isfile(video_path):
  52. return False
  53. valid_extensions = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm']
  54. ext = os.path.splitext(video_path)[1].lower()
  55. if ext not in valid_extensions:
  56. return False
  57. if os.path.getsize(video_path) < 1024:
  58. return False
  59. return True
  60. # 创建 Flask 应用
  61. app = Flask(__name__)
  62. CORS(app)
  63. # 全局配置
  64. HEADLESS_MODE = os.environ.get('HEADLESS', 'true').lower() == 'true'
  65. # ==================== 签名相关(小红书专用) ====================
  66. @app.route("/sign", methods=["POST"])
  67. def sign_endpoint():
  68. """小红书签名接口"""
  69. try:
  70. from platforms.xiaohongshu import XiaohongshuPublisher
  71. data = request.json
  72. publisher = XiaohongshuPublisher(headless=True)
  73. result = asyncio.run(publisher.get_sign(
  74. data.get("uri", ""),
  75. data.get("data"),
  76. data.get("a1", ""),
  77. data.get("web_session", "")
  78. ))
  79. return jsonify(result)
  80. except Exception as e:
  81. traceback.print_exc()
  82. return jsonify({"error": str(e)}), 500
  83. # ==================== 统一发布接口 ====================
  84. @app.route("/publish", methods=["POST"])
  85. def publish_video():
  86. """
  87. 统一发布接口
  88. 请求体:
  89. {
  90. "platform": "douyin", # douyin | xiaohongshu | weixin | kuaishou
  91. "cookie": "cookie字符串或JSON",
  92. "title": "视频标题",
  93. "description": "视频描述(可选)",
  94. "video_path": "视频文件绝对路径",
  95. "cover_path": "封面图片绝对路径(可选)",
  96. "tags": ["话题1", "话题2"],
  97. "post_time": "定时发布时间(可选,格式:2024-01-20 12:00:00)",
  98. "location": "位置(可选,默认:重庆市)"
  99. }
  100. 响应:
  101. {
  102. "success": true,
  103. "platform": "douyin",
  104. "video_id": "xxx",
  105. "video_url": "xxx",
  106. "message": "发布成功"
  107. }
  108. """
  109. try:
  110. data = request.json
  111. # 获取参数
  112. platform = data.get("platform", "").lower()
  113. cookie_str = data.get("cookie", "")
  114. title = data.get("title", "")
  115. description = data.get("description", "")
  116. video_path = data.get("video_path", "")
  117. cover_path = data.get("cover_path")
  118. tags = data.get("tags", [])
  119. post_time = data.get("post_time")
  120. location = data.get("location", "重庆市")
  121. # 调试日志
  122. print(f"[Publish] 收到请求: platform={platform}, title={title}, video_path={video_path}")
  123. # 参数验证
  124. if not platform:
  125. print("[Publish] 错误: 缺少 platform 参数")
  126. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  127. if platform not in PLATFORM_MAP:
  128. print(f"[Publish] 错误: 不支持的平台 {platform}")
  129. return jsonify({
  130. "success": False,
  131. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  132. }), 400
  133. if not cookie_str:
  134. print("[Publish] 错误: 缺少 cookie 参数")
  135. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  136. if not title:
  137. print("[Publish] 错误: 缺少 title 参数")
  138. return jsonify({"success": False, "error": "缺少 title 参数"}), 400
  139. if not video_path:
  140. print("[Publish] 错误: 缺少 video_path 参数")
  141. return jsonify({"success": False, "error": "缺少 video_path 参数"}), 400
  142. # 视频文件验证(增加详细信息)
  143. if not os.path.exists(video_path):
  144. print(f"[Publish] 错误: 视频文件不存在: {video_path}")
  145. return jsonify({"success": False, "error": f"视频文件不存在: {video_path}"}), 400
  146. if not os.path.isfile(video_path):
  147. print(f"[Publish] 错误: 路径不是文件: {video_path}")
  148. return jsonify({"success": False, "error": f"路径不是文件: {video_path}"}), 400
  149. # 解析发布时间
  150. publish_date = parse_datetime(post_time) if post_time else None
  151. # 创建发布参数
  152. params = PublishParams(
  153. title=title,
  154. video_path=video_path,
  155. description=description,
  156. cover_path=cover_path,
  157. tags=tags,
  158. publish_date=publish_date,
  159. location=location
  160. )
  161. print("=" * 60)
  162. print(f"[Publish] 平台: {platform}")
  163. print(f"[Publish] 标题: {title}")
  164. print(f"[Publish] 视频: {video_path}")
  165. print(f"[Publish] 封面: {cover_path}")
  166. print(f"[Publish] 话题: {tags}")
  167. print(f"[Publish] 定时: {publish_date}")
  168. print("=" * 60)
  169. # 获取对应平台的发布器
  170. PublisherClass = get_publisher(platform)
  171. publisher = PublisherClass(headless=HEADLESS_MODE)
  172. # 执行发布
  173. result = asyncio.run(publisher.run(cookie_str, params))
  174. return jsonify({
  175. "success": result.success,
  176. "platform": result.platform,
  177. "video_id": result.video_id,
  178. "video_url": result.video_url,
  179. "message": result.message,
  180. "error": result.error
  181. })
  182. except Exception as e:
  183. traceback.print_exc()
  184. return jsonify({"success": False, "error": str(e)}), 500
  185. # ==================== 批量发布接口 ====================
  186. @app.route("/publish/batch", methods=["POST"])
  187. def publish_batch():
  188. """
  189. 批量发布接口 - 发布到多个平台
  190. 请求体:
  191. {
  192. "platforms": ["douyin", "xiaohongshu"],
  193. "cookies": {
  194. "douyin": "cookie字符串",
  195. "xiaohongshu": "cookie字符串"
  196. },
  197. "title": "视频标题",
  198. "video_path": "视频文件绝对路径",
  199. ...
  200. }
  201. """
  202. try:
  203. data = request.json
  204. platforms = data.get("platforms", [])
  205. cookies = data.get("cookies", {})
  206. if not platforms:
  207. return jsonify({"success": False, "error": "缺少 platforms 参数"}), 400
  208. results = []
  209. for platform in platforms:
  210. platform = platform.lower()
  211. cookie_str = cookies.get(platform, "")
  212. if not cookie_str:
  213. results.append({
  214. "platform": platform,
  215. "success": False,
  216. "error": f"缺少 {platform} 的 cookie"
  217. })
  218. continue
  219. try:
  220. # 创建参数
  221. params = PublishParams(
  222. title=data.get("title", ""),
  223. video_path=data.get("video_path", ""),
  224. description=data.get("description", ""),
  225. cover_path=data.get("cover_path"),
  226. tags=data.get("tags", []),
  227. publish_date=parse_datetime(data.get("post_time")),
  228. location=data.get("location", "重庆市")
  229. )
  230. # 发布
  231. PublisherClass = get_publisher(platform)
  232. publisher = PublisherClass(headless=HEADLESS_MODE)
  233. result = asyncio.run(publisher.run(cookie_str, params))
  234. results.append({
  235. "platform": result.platform,
  236. "success": result.success,
  237. "video_id": result.video_id,
  238. "message": result.message,
  239. "error": result.error
  240. })
  241. except Exception as e:
  242. results.append({
  243. "platform": platform,
  244. "success": False,
  245. "error": str(e)
  246. })
  247. # 统计成功/失败数量
  248. success_count = sum(1 for r in results if r.get("success"))
  249. return jsonify({
  250. "success": success_count > 0,
  251. "total": len(platforms),
  252. "success_count": success_count,
  253. "fail_count": len(platforms) - success_count,
  254. "results": results
  255. })
  256. except Exception as e:
  257. traceback.print_exc()
  258. return jsonify({"success": False, "error": str(e)}), 500
  259. # ==================== Cookie 验证接口 ====================
  260. @app.route("/check_cookie", methods=["POST"])
  261. def check_cookie():
  262. """检查 cookie 是否有效"""
  263. try:
  264. data = request.json
  265. platform = data.get("platform", "").lower()
  266. cookie_str = data.get("cookie", "")
  267. if not cookie_str:
  268. return jsonify({"valid": False, "error": "缺少 cookie 参数"}), 400
  269. # 目前只支持小红书的 cookie 验证
  270. if platform == "xiaohongshu":
  271. try:
  272. from platforms.xiaohongshu import XiaohongshuPublisher, XHS_SDK_AVAILABLE
  273. if XHS_SDK_AVAILABLE:
  274. from xhs import XhsClient
  275. publisher = XiaohongshuPublisher()
  276. xhs_client = XhsClient(cookie_str, sign=publisher.sign_sync)
  277. info = xhs_client.get_self_info()
  278. if info:
  279. return jsonify({
  280. "valid": True,
  281. "user_info": {
  282. "user_id": info.get("user_id"),
  283. "nickname": info.get("nickname"),
  284. "avatar": info.get("images")
  285. }
  286. })
  287. except Exception as e:
  288. return jsonify({"valid": False, "error": str(e)})
  289. # 其他平台返回格式正确但未验证
  290. return jsonify({
  291. "valid": True,
  292. "message": "Cookie 格式正确,但未进行在线验证"
  293. })
  294. except Exception as e:
  295. traceback.print_exc()
  296. return jsonify({"valid": False, "error": str(e)})
  297. # ==================== 获取作品列表接口 ====================
  298. @app.route("/works", methods=["POST"])
  299. def get_works():
  300. """
  301. 获取作品列表
  302. 请求体:
  303. {
  304. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  305. "cookie": "cookie字符串或JSON",
  306. "page": 0, # 页码(从0开始,可选,默认0)
  307. "page_size": 20 # 每页数量(可选,默认20)
  308. }
  309. 响应:
  310. {
  311. "success": true,
  312. "platform": "douyin",
  313. "works": [...],
  314. "total": 100,
  315. "has_more": true
  316. }
  317. """
  318. try:
  319. data = request.json
  320. platform = data.get("platform", "").lower()
  321. cookie_str = data.get("cookie", "")
  322. page = data.get("page", 0)
  323. page_size = data.get("page_size", 20)
  324. print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}")
  325. if not platform:
  326. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  327. if platform not in PLATFORM_MAP:
  328. return jsonify({
  329. "success": False,
  330. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  331. }), 400
  332. if not cookie_str:
  333. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  334. # 获取对应平台的发布器
  335. PublisherClass = get_publisher(platform)
  336. publisher = PublisherClass(headless=HEADLESS_MODE)
  337. # 执行获取作品
  338. result = asyncio.run(publisher.run_get_works(cookie_str, page, page_size))
  339. return jsonify(result.to_dict())
  340. except Exception as e:
  341. traceback.print_exc()
  342. return jsonify({"success": False, "error": str(e)}), 500
  343. # ==================== 获取评论列表接口 ====================
  344. @app.route("/comments", methods=["POST"])
  345. def get_comments():
  346. """
  347. 获取作品评论
  348. 请求体:
  349. {
  350. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  351. "cookie": "cookie字符串或JSON",
  352. "work_id": "作品ID",
  353. "cursor": "" # 分页游标(可选)
  354. }
  355. 响应:
  356. {
  357. "success": true,
  358. "platform": "douyin",
  359. "work_id": "xxx",
  360. "comments": [...],
  361. "total": 50,
  362. "has_more": true,
  363. "cursor": "xxx"
  364. }
  365. """
  366. try:
  367. data = request.json
  368. platform = data.get("platform", "").lower()
  369. cookie_str = data.get("cookie", "")
  370. work_id = data.get("work_id", "")
  371. cursor = data.get("cursor", "")
  372. print(f"[Comments] 收到请求: platform={platform}, work_id={work_id}")
  373. if not platform:
  374. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  375. if platform not in PLATFORM_MAP:
  376. return jsonify({
  377. "success": False,
  378. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  379. }), 400
  380. if not cookie_str:
  381. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  382. if not work_id:
  383. return jsonify({"success": False, "error": "缺少 work_id 参数"}), 400
  384. # 获取对应平台的发布器
  385. PublisherClass = get_publisher(platform)
  386. publisher = PublisherClass(headless=HEADLESS_MODE)
  387. # 执行获取评论
  388. result = asyncio.run(publisher.run_get_comments(cookie_str, work_id, cursor))
  389. result_dict = result.to_dict()
  390. # 添加 cursor 到响应
  391. if hasattr(result, '__dict__') and 'cursor' in result.__dict__:
  392. result_dict['cursor'] = result.__dict__['cursor']
  393. return jsonify(result_dict)
  394. except Exception as e:
  395. traceback.print_exc()
  396. return jsonify({"success": False, "error": str(e)}), 500
  397. # ==================== 获取所有作品评论接口 ====================
  398. @app.route("/all_comments", methods=["POST"])
  399. def get_all_comments():
  400. """
  401. 获取所有作品的评论(一次性获取)
  402. 请求体:
  403. {
  404. "platform": "douyin", # douyin | xiaohongshu
  405. "cookie": "cookie字符串或JSON"
  406. }
  407. 响应:
  408. {
  409. "success": true,
  410. "platform": "douyin",
  411. "work_comments": [
  412. {
  413. "work_id": "xxx",
  414. "title": "作品标题",
  415. "cover_url": "封面URL",
  416. "comments": [...]
  417. }
  418. ],
  419. "total": 5
  420. }
  421. """
  422. try:
  423. data = request.json
  424. platform = data.get("platform", "").lower()
  425. cookie_str = data.get("cookie", "")
  426. print(f"[AllComments] 收到请求: platform={platform}")
  427. if not platform:
  428. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  429. if platform not in ['douyin', 'xiaohongshu']:
  430. return jsonify({
  431. "success": False,
  432. "error": f"该接口只支持 douyin 和 xiaohongshu 平台"
  433. }), 400
  434. if not cookie_str:
  435. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  436. # 获取对应平台的发布器
  437. PublisherClass = get_publisher(platform)
  438. publisher = PublisherClass(headless=HEADLESS_MODE)
  439. # 执行获取所有评论
  440. result = asyncio.run(publisher.get_all_comments(cookie_str))
  441. return jsonify(result)
  442. except Exception as e:
  443. traceback.print_exc()
  444. return jsonify({"success": False, "error": str(e)}), 500
  445. # ==================== 健康检查 ====================
  446. @app.route("/health", methods=["GET"])
  447. def health_check():
  448. """健康检查"""
  449. # 检查 xhs SDK 是否可用
  450. xhs_available = False
  451. try:
  452. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  453. xhs_available = XHS_SDK_AVAILABLE
  454. except:
  455. pass
  456. return jsonify({
  457. "status": "ok",
  458. "xhs_sdk": xhs_available,
  459. "supported_platforms": list(PLATFORM_MAP.keys()),
  460. "headless_mode": HEADLESS_MODE
  461. })
  462. @app.route("/", methods=["GET"])
  463. def index():
  464. """首页"""
  465. return jsonify({
  466. "name": "多平台视频发布服务",
  467. "version": "1.1.0",
  468. "endpoints": {
  469. "GET /": "服务信息",
  470. "GET /health": "健康检查",
  471. "POST /publish": "发布视频",
  472. "POST /publish/batch": "批量发布",
  473. "POST /works": "获取作品列表",
  474. "POST /comments": "获取作品评论",
  475. "POST /all_comments": "获取所有作品评论",
  476. "POST /check_cookie": "检查 Cookie",
  477. "POST /sign": "小红书签名"
  478. },
  479. "supported_platforms": list(PLATFORM_MAP.keys())
  480. })
  481. # ==================== 命令行启动 ====================
  482. def main():
  483. parser = argparse.ArgumentParser(description='多平台视频发布服务')
  484. parser.add_argument('--port', type=int, default=5005, help='服务端口 (默认: 5005)')
  485. parser.add_argument('--host', type=str, default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
  486. parser.add_argument('--headless', type=str, default='true', help='是否无头模式 (默认: true)')
  487. parser.add_argument('--debug', action='store_true', help='调试模式')
  488. args = parser.parse_args()
  489. global HEADLESS_MODE
  490. HEADLESS_MODE = args.headless.lower() == 'true'
  491. # 检查 xhs SDK
  492. xhs_status = "未安装"
  493. try:
  494. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  495. xhs_status = "已安装" if XHS_SDK_AVAILABLE else "未安装"
  496. except:
  497. pass
  498. print("=" * 60)
  499. print("多平台视频发布服务")
  500. print("=" * 60)
  501. print(f"XHS SDK: {xhs_status}")
  502. print(f"Headless 模式: {HEADLESS_MODE}")
  503. print(f"支持平台: {', '.join(PLATFORM_MAP.keys())}")
  504. print("=" * 60)
  505. print(f"启动服务: http://{args.host}:{args.port}")
  506. print("=" * 60)
  507. app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
  508. if __name__ == '__main__':
  509. main()