app.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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. response_data = {
  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. "need_captcha": result.need_captcha,
  182. "captcha_type": result.captcha_type
  183. }
  184. # 如果需要验证码,打印明确的日志
  185. if result.need_captcha:
  186. print(f"[Publish] 需要验证码: type={result.captcha_type}")
  187. return jsonify(response_data)
  188. except Exception as e:
  189. traceback.print_exc()
  190. return jsonify({"success": False, "error": str(e)}), 500
  191. # ==================== 批量发布接口 ====================
  192. @app.route("/publish/batch", methods=["POST"])
  193. def publish_batch():
  194. """
  195. 批量发布接口 - 发布到多个平台
  196. 请求体:
  197. {
  198. "platforms": ["douyin", "xiaohongshu"],
  199. "cookies": {
  200. "douyin": "cookie字符串",
  201. "xiaohongshu": "cookie字符串"
  202. },
  203. "title": "视频标题",
  204. "video_path": "视频文件绝对路径",
  205. ...
  206. }
  207. """
  208. try:
  209. data = request.json
  210. platforms = data.get("platforms", [])
  211. cookies = data.get("cookies", {})
  212. if not platforms:
  213. return jsonify({"success": False, "error": "缺少 platforms 参数"}), 400
  214. results = []
  215. for platform in platforms:
  216. platform = platform.lower()
  217. cookie_str = cookies.get(platform, "")
  218. if not cookie_str:
  219. results.append({
  220. "platform": platform,
  221. "success": False,
  222. "error": f"缺少 {platform} 的 cookie"
  223. })
  224. continue
  225. try:
  226. # 创建参数
  227. params = PublishParams(
  228. title=data.get("title", ""),
  229. video_path=data.get("video_path", ""),
  230. description=data.get("description", ""),
  231. cover_path=data.get("cover_path"),
  232. tags=data.get("tags", []),
  233. publish_date=parse_datetime(data.get("post_time")),
  234. location=data.get("location", "重庆市")
  235. )
  236. # 发布
  237. PublisherClass = get_publisher(platform)
  238. publisher = PublisherClass(headless=HEADLESS_MODE)
  239. result = asyncio.run(publisher.run(cookie_str, params))
  240. results.append({
  241. "platform": result.platform,
  242. "success": result.success,
  243. "video_id": result.video_id,
  244. "message": result.message,
  245. "error": result.error
  246. })
  247. except Exception as e:
  248. results.append({
  249. "platform": platform,
  250. "success": False,
  251. "error": str(e)
  252. })
  253. # 统计成功/失败数量
  254. success_count = sum(1 for r in results if r.get("success"))
  255. return jsonify({
  256. "success": success_count > 0,
  257. "total": len(platforms),
  258. "success_count": success_count,
  259. "fail_count": len(platforms) - success_count,
  260. "results": results
  261. })
  262. except Exception as e:
  263. traceback.print_exc()
  264. return jsonify({"success": False, "error": str(e)}), 500
  265. # ==================== Cookie 验证接口 ====================
  266. @app.route("/check_cookie", methods=["POST"])
  267. def check_cookie():
  268. """检查 cookie 是否有效"""
  269. try:
  270. data = request.json
  271. platform = data.get("platform", "").lower()
  272. cookie_str = data.get("cookie", "")
  273. if not cookie_str:
  274. return jsonify({"valid": False, "error": "缺少 cookie 参数"}), 400
  275. # 目前只支持小红书的 cookie 验证
  276. if platform == "xiaohongshu":
  277. try:
  278. from platforms.xiaohongshu import XiaohongshuPublisher, XHS_SDK_AVAILABLE
  279. if XHS_SDK_AVAILABLE:
  280. from xhs import XhsClient
  281. publisher = XiaohongshuPublisher()
  282. xhs_client = XhsClient(cookie_str, sign=publisher.sign_sync)
  283. info = xhs_client.get_self_info()
  284. if info:
  285. return jsonify({
  286. "valid": True,
  287. "user_info": {
  288. "user_id": info.get("user_id"),
  289. "nickname": info.get("nickname"),
  290. "avatar": info.get("images")
  291. }
  292. })
  293. except Exception as e:
  294. return jsonify({"valid": False, "error": str(e)})
  295. # 其他平台返回格式正确但未验证
  296. return jsonify({
  297. "valid": True,
  298. "message": "Cookie 格式正确,但未进行在线验证"
  299. })
  300. except Exception as e:
  301. traceback.print_exc()
  302. return jsonify({"valid": False, "error": str(e)})
  303. # ==================== 获取作品列表接口 ====================
  304. @app.route("/works", methods=["POST"])
  305. def get_works():
  306. """
  307. 获取作品列表
  308. 请求体:
  309. {
  310. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  311. "cookie": "cookie字符串或JSON",
  312. "page": 0, # 页码(从0开始,可选,默认0)
  313. "page_size": 20 # 每页数量(可选,默认20)
  314. }
  315. 响应:
  316. {
  317. "success": true,
  318. "platform": "douyin",
  319. "works": [...],
  320. "total": 100,
  321. "has_more": true
  322. }
  323. """
  324. try:
  325. data = request.json
  326. platform = data.get("platform", "").lower()
  327. cookie_str = data.get("cookie", "")
  328. page = data.get("page", 0)
  329. page_size = data.get("page_size", 20)
  330. print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}")
  331. if not platform:
  332. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  333. if platform not in PLATFORM_MAP:
  334. return jsonify({
  335. "success": False,
  336. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  337. }), 400
  338. if not cookie_str:
  339. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  340. # 获取对应平台的发布器
  341. PublisherClass = get_publisher(platform)
  342. publisher = PublisherClass(headless=HEADLESS_MODE)
  343. # 执行获取作品
  344. result = asyncio.run(publisher.run_get_works(cookie_str, page, page_size))
  345. return jsonify(result.to_dict())
  346. except Exception as e:
  347. traceback.print_exc()
  348. return jsonify({"success": False, "error": str(e)}), 500
  349. # ==================== 获取评论列表接口 ====================
  350. @app.route("/comments", methods=["POST"])
  351. def get_comments():
  352. """
  353. 获取作品评论
  354. 请求体:
  355. {
  356. "platform": "douyin", # douyin | xiaohongshu | kuaishou
  357. "cookie": "cookie字符串或JSON",
  358. "work_id": "作品ID",
  359. "cursor": "" # 分页游标(可选)
  360. }
  361. 响应:
  362. {
  363. "success": true,
  364. "platform": "douyin",
  365. "work_id": "xxx",
  366. "comments": [...],
  367. "total": 50,
  368. "has_more": true,
  369. "cursor": "xxx"
  370. }
  371. """
  372. try:
  373. data = request.json
  374. platform = data.get("platform", "").lower()
  375. cookie_str = data.get("cookie", "")
  376. work_id = data.get("work_id", "")
  377. cursor = data.get("cursor", "")
  378. print(f"[Comments] 收到请求: platform={platform}, work_id={work_id}")
  379. if not platform:
  380. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  381. if platform not in PLATFORM_MAP:
  382. return jsonify({
  383. "success": False,
  384. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  385. }), 400
  386. if not cookie_str:
  387. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  388. if not work_id:
  389. return jsonify({"success": False, "error": "缺少 work_id 参数"}), 400
  390. # 获取对应平台的发布器
  391. PublisherClass = get_publisher(platform)
  392. publisher = PublisherClass(headless=HEADLESS_MODE)
  393. # 执行获取评论
  394. result = asyncio.run(publisher.run_get_comments(cookie_str, work_id, cursor))
  395. result_dict = result.to_dict()
  396. # 添加 cursor 到响应
  397. if hasattr(result, '__dict__') and 'cursor' in result.__dict__:
  398. result_dict['cursor'] = result.__dict__['cursor']
  399. return jsonify(result_dict)
  400. except Exception as e:
  401. traceback.print_exc()
  402. return jsonify({"success": False, "error": str(e)}), 500
  403. # ==================== 获取所有作品评论接口 ====================
  404. @app.route("/all_comments", methods=["POST"])
  405. def get_all_comments():
  406. """
  407. 获取所有作品的评论(一次性获取)
  408. 请求体:
  409. {
  410. "platform": "douyin", # douyin | xiaohongshu
  411. "cookie": "cookie字符串或JSON"
  412. }
  413. 响应:
  414. {
  415. "success": true,
  416. "platform": "douyin",
  417. "work_comments": [
  418. {
  419. "work_id": "xxx",
  420. "title": "作品标题",
  421. "cover_url": "封面URL",
  422. "comments": [...]
  423. }
  424. ],
  425. "total": 5
  426. }
  427. """
  428. try:
  429. data = request.json
  430. platform = data.get("platform", "").lower()
  431. cookie_str = data.get("cookie", "")
  432. print(f"[AllComments] 收到请求: platform={platform}")
  433. if not platform:
  434. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  435. if platform not in ['douyin', 'xiaohongshu']:
  436. return jsonify({
  437. "success": False,
  438. "error": f"该接口只支持 douyin 和 xiaohongshu 平台"
  439. }), 400
  440. if not cookie_str:
  441. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  442. # 获取对应平台的发布器
  443. PublisherClass = get_publisher(platform)
  444. publisher = PublisherClass(headless=HEADLESS_MODE)
  445. # 执行获取所有评论
  446. result = asyncio.run(publisher.get_all_comments(cookie_str))
  447. return jsonify(result)
  448. except Exception as e:
  449. traceback.print_exc()
  450. return jsonify({"success": False, "error": str(e)}), 500
  451. # ==================== 登录状态检查接口 ====================
  452. @app.route("/check_login", methods=["POST"])
  453. def check_login():
  454. """
  455. 检查 Cookie 登录状态(通过浏览器访问后台页面检测)
  456. 请求体:
  457. {
  458. "platform": "douyin", # douyin | xiaohongshu | kuaishou | weixin
  459. "cookie": "cookie字符串或JSON"
  460. }
  461. 响应:
  462. {
  463. "success": true,
  464. "valid": true, # Cookie 是否有效
  465. "need_login": false, # 是否需要重新登录
  466. "message": "登录状态有效"
  467. }
  468. """
  469. try:
  470. data = request.json
  471. platform = data.get("platform", "").lower()
  472. cookie_str = data.get("cookie", "")
  473. print(f"[CheckLogin] 收到请求: platform={platform}")
  474. if not platform:
  475. return jsonify({"success": False, "error": "缺少 platform 参数"}), 400
  476. if platform not in PLATFORM_MAP:
  477. return jsonify({
  478. "success": False,
  479. "error": f"不支持的平台: {platform},支持: {list(PLATFORM_MAP.keys())}"
  480. }), 400
  481. if not cookie_str:
  482. return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
  483. # 获取对应平台的发布器
  484. PublisherClass = get_publisher(platform)
  485. publisher = PublisherClass(headless=HEADLESS_MODE)
  486. # 执行登录检查
  487. result = asyncio.run(publisher.check_login_status(cookie_str))
  488. return jsonify(result)
  489. except Exception as e:
  490. traceback.print_exc()
  491. return jsonify({
  492. "success": False,
  493. "valid": False,
  494. "need_login": True,
  495. "error": str(e)
  496. }), 500
  497. # ==================== 健康检查 ====================
  498. @app.route("/health", methods=["GET"])
  499. def health_check():
  500. """健康检查"""
  501. # 检查 xhs SDK 是否可用
  502. xhs_available = False
  503. try:
  504. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  505. xhs_available = XHS_SDK_AVAILABLE
  506. except:
  507. pass
  508. return jsonify({
  509. "status": "ok",
  510. "xhs_sdk": xhs_available,
  511. "supported_platforms": list(PLATFORM_MAP.keys()),
  512. "headless_mode": HEADLESS_MODE
  513. })
  514. @app.route("/", methods=["GET"])
  515. def index():
  516. """首页"""
  517. return jsonify({
  518. "name": "多平台视频发布服务",
  519. "version": "1.1.0",
  520. "endpoints": {
  521. "GET /": "服务信息",
  522. "GET /health": "健康检查",
  523. "POST /publish": "发布视频",
  524. "POST /publish/batch": "批量发布",
  525. "POST /works": "获取作品列表",
  526. "POST /comments": "获取作品评论",
  527. "POST /all_comments": "获取所有作品评论",
  528. "POST /check_cookie": "检查 Cookie",
  529. "POST /sign": "小红书签名"
  530. },
  531. "supported_platforms": list(PLATFORM_MAP.keys())
  532. })
  533. # ==================== 命令行启动 ====================
  534. def main():
  535. parser = argparse.ArgumentParser(description='多平台视频发布服务')
  536. parser.add_argument('--port', type=int, default=5005, help='服务端口 (默认: 5005)')
  537. parser.add_argument('--host', type=str, default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
  538. parser.add_argument('--headless', type=str, default='true', help='是否无头模式 (默认: true)')
  539. parser.add_argument('--debug', action='store_true', help='调试模式')
  540. args = parser.parse_args()
  541. global HEADLESS_MODE
  542. HEADLESS_MODE = args.headless.lower() == 'true'
  543. # 检查 xhs SDK
  544. xhs_status = "未安装"
  545. try:
  546. from platforms.xiaohongshu import XHS_SDK_AVAILABLE
  547. xhs_status = "已安装" if XHS_SDK_AVAILABLE else "未安装"
  548. except:
  549. pass
  550. print("=" * 60)
  551. print("多平台视频发布服务")
  552. print("=" * 60)
  553. print(f"XHS SDK: {xhs_status}")
  554. print(f"Headless 模式: {HEADLESS_MODE}")
  555. print(f"支持平台: {', '.join(PLATFORM_MAP.keys())}")
  556. print("=" * 60)
  557. print(f"启动服务: http://{args.host}:{args.port}")
  558. print("=" * 60)
  559. app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
  560. if __name__ == '__main__':
  561. main()