返回 Social Auto Upload
sau_cli.py
根目录 / sau_cli.py
1 from __future__ import annotations
2
3 import argparse
4 import asyncio
5 import sys
6 from dataclasses import dataclass
7 from datetime import datetime
8 from pathlib import Path
9 from typing import Iterable, Sequence
10
11 from conf import BASE_DIR
12 from uploader.bilibili_uploader.runtime import run_biliup_command
13 from uploader.douyin_uploader.main import (
14 DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
15 DOUYIN_PUBLISH_STRATEGY_SCHEDULED,
16 DouYinNote,
17 DouYinVideo,
18 cookie_auth as douyin_cookie_auth,
19 douyin_setup,
20 )
21 from uploader.ks_uploader.main import (
22 KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE,
23 KUAISHOU_PUBLISH_STRATEGY_SCHEDULED,
24 KSNote,
25 KSVideo,
26 cookie_auth as kuaishou_cookie_auth,
27 ks_setup,
28 )
29 from uploader.tencent_uploader.main import (
30 TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
31 TENCENT_PUBLISH_STRATEGY_SCHEDULED,
32 TencentVideo,
33 cookie_auth as tencent_cookie_auth,
34 tencent_setup,
35 )
36 from uploader.xiaohongshu_uploader.main import (
37 XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
38 XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED,
39 XiaoHongShuNote,
40 XiaoHongShuVideo,
41 cookie_auth as xiaohongshu_cookie_auth,
42 xiaohongshu_setup,
43 )
44 from uploader.youtube_uploader.main import (
45 YouTubeVideo,
46 cookie_auth as youtube_cookie_auth,
47 youtube_setup,
48 )
49
50 SCHEDULE_FORMAT = "%Y-%m-%d %H:%M"
51
52
53 @dataclass(slots=True)
54 class DouyinVideoUploadRequest:
55 account_name: str
56 video_file: Path
57 title: str
58 description: str
59 tags: list[str]
60 publish_date: datetime | int
61 thumbnail_file: Path | None = None
62 thumbnail_landscape_file: Path | None = None
63 thumbnail_portrait_file: Path | None = None
64 product_link: str = ""
65 product_title: str = ""
66 publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE
67 debug: bool = True
68 headless: bool = True
69 declaration: str | None = None
70
71
72 @dataclass(slots=True)
73 class DouyinNoteUploadRequest:
74 account_name: str
75 image_files: list[Path]
76 title: str
77 note: str
78 tags: list[str]
79 publish_date: datetime | int
80 publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE
81 debug: bool = True
82 headless: bool = True
83 bgm: str = ""
84
85
86 @dataclass(slots=True)
87 class KuaishouVideoUploadRequest:
88 account_name: str
89 video_file: Path
90 title: str
91 description: str
92 tags: list[str]
93 publish_date: datetime | int
94 thumbnail_file: Path | None = None
95 publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE
96 debug: bool = True
97 headless: bool = True
98
99
100 @dataclass(slots=True)
101 class KuaishouNoteUploadRequest:
102 account_name: str
103 image_files: list[Path]
104 title: str
105 note: str
106 tags: list[str]
107 publish_date: datetime | int
108 publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE
109 debug: bool = True
110 headless: bool = True
111
112
113 @dataclass(slots=True)
114 class XiaohongshuVideoUploadRequest:
115 account_name: str
116 video_file: Path
117 title: str
118 description: str
119 tags: list[str]
120 publish_date: datetime | int
121 thumbnail_file: Path | None = None
122 publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
123 debug: bool = True
124 headless: bool = True
125
126
127 @dataclass(slots=True)
128 class XiaohongshuNoteUploadRequest:
129 account_name: str
130 image_files: list[Path]
131 title: str
132 note: str
133 tags: list[str]
134 publish_date: datetime | int
135 publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
136 debug: bool = True
137 headless: bool = True
138
139
140 @dataclass(slots=True)
141 class BilibiliVideoUploadRequest:
142 account_name: str
143 video_file: Path
144 title: str
145 description: str
146 tid: int
147 tags: list[str]
148 publish_date: datetime | int
149 thumbnail_file: Path | None = None
150
151
152 @dataclass(slots=True)
153 class TencentVideoUploadRequest:
154 account_name: str
155 video_file: Path
156 title: str
157 description: str
158 tags: list[str]
159 publish_date: datetime | int
160 thumbnail_file: Path | None = None
161 thumbnail_landscape_file: Path | None = None
162 thumbnail_portrait_file: Path | None = None
163 short_title: str | None = None
164 category: str | None = None
165 is_draft: bool = False
166 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE
167 debug: bool = True
168 headless: bool = True
169
170
171 @dataclass(slots=True)
172 class YouTubeVideoUploadRequest:
173 account_name: str
174 video_file: Path
175 title: str
176 description: str
177 tags: list[str]
178 thumbnail_file: Path | None = None
179 playlist: str | None = None
180 visibility: str = "public"
181 debug: bool = True
182 headless: bool = False
183
184
185 def has_interactive_terminal() -> bool:
186 return sys.stdin.isatty() and sys.stdout.isatty()
187
188
189 def resolve_runtime_home() -> Path:
190 return Path(BASE_DIR)
191
192
193 def resolve_account_file(platform: str, account_name: str) -> Path:
194 account_file = resolve_runtime_home() / "cookies" / f"{platform}_{account_name}.json"
195 account_file.parent.mkdir(exist_ok=True)
196 return account_file
197
198
199 def parse_tags(raw_tags: str | None) -> list[str]:
200 if not raw_tags:
201 return []
202
203 tags: list[str] = []
204 for item in raw_tags.split(","):
205 cleaned = item.strip().lstrip("#")
206 if cleaned:
207 tags.append(cleaned)
208 return tags
209
210
211 def parse_image_files(raw_files: Iterable[Path]) -> list[Path]:
212 return [Path(file) for file in raw_files]
213
214
215 def parse_schedule(raw_schedule: str | None) -> datetime | int:
216 if not raw_schedule:
217 return 0
218 return datetime.strptime(raw_schedule, SCHEDULE_FORMAT)
219
220
221 async def login_douyin_account(account_name: str, headless: bool = True) -> dict:
222 account_file = resolve_account_file("douyin", account_name)
223 return await douyin_setup(str(account_file), handle=True, return_detail=True, headless=headless)
224
225
226 async def check_douyin_account(account_name: str) -> bool:
227 account_file = resolve_account_file("douyin", account_name)
228 if not account_file.exists():
229 return False
230 return await douyin_cookie_auth(str(account_file))
231
232
233 async def login_kuaishou_account(account_name: str, headless: bool = True) -> dict:
234 account_file = resolve_account_file("kuaishou", account_name)
235 return await ks_setup(str(account_file), handle=True, return_detail=True, headless=headless)
236
237
238 async def check_kuaishou_account(account_name: str) -> bool:
239 account_file = resolve_account_file("kuaishou", account_name)
240 if not account_file.exists():
241 return False
242 return await kuaishou_cookie_auth(str(account_file))
243
244
245 async def login_xiaohongshu_account(account_name: str, headless: bool = True) -> dict:
246 account_file = resolve_account_file("xiaohongshu", account_name)
247 return await xiaohongshu_setup(str(account_file), handle=True, return_detail=True, headless=headless)
248
249
250 async def check_xiaohongshu_account(account_name: str) -> bool:
251 account_file = resolve_account_file("xiaohongshu", account_name)
252 if not account_file.exists():
253 return False
254 return await xiaohongshu_cookie_auth(str(account_file))
255
256
257 async def login_bilibili_account(account_name: str) -> dict:
258 account_file = resolve_account_file("bilibili", account_name)
259 if not has_interactive_terminal():
260 return {
261 "success": False,
262 "message": (
263 "Bilibili login requires a local interactive terminal. "
264 f"Please run `sau bilibili login --account {account_name}` yourself in a local terminal. "
265 "If the terminal QR code does not render completely, open `./qrcode.png` and scan that image."
266 ),
267 "account_file": str(account_file),
268 }
269
270 result = run_biliup_command(["-u", str(account_file), "login"], interactive=True)
271 success = result.returncode == 0
272 return {
273 "success": success,
274 "message": (result.stderr or result.stdout or "").strip() or "Bilibili login completed" if success else (result.stderr or result.stdout or "").strip() or "Bilibili login failed",
275 "account_file": str(account_file),
276 }
277
278
279 async def check_bilibili_account(account_name: str) -> bool:
280 account_file = resolve_account_file("bilibili", account_name)
281 if not account_file.exists():
282 return False
283 result = run_biliup_command(["-u", str(account_file), "renew"])
284 return result.returncode == 0
285
286
287 async def login_tencent_account(account_name: str, headless: bool = True) -> dict:
288 account_file = resolve_account_file("tencent", account_name)
289 return await tencent_setup(str(account_file), handle=True, return_detail=True, headless=headless)
290
291
292 async def check_tencent_account(account_name: str) -> bool:
293 account_file = resolve_account_file("tencent", account_name)
294 if not account_file.exists():
295 return False
296 return await tencent_cookie_auth(str(account_file))
297
298
299 async def login_youtube_account(account_name: str, headless: bool = False) -> dict:
300 account_file = resolve_account_file("youtube", account_name)
301 return await youtube_setup(str(account_file), handle=True, return_detail=True, headless=headless)
302
303
304 async def check_youtube_account(account_name: str) -> bool:
305 account_file = resolve_account_file("youtube", account_name)
306 if not account_file.exists():
307 return False
308 return await youtube_cookie_auth(str(account_file))
309
310
311 async def upload_youtube_video(request: YouTubeVideoUploadRequest) -> Path:
312 account_file = resolve_account_file("youtube", request.account_name)
313 is_ready = await youtube_setup(str(account_file), handle=False)
314 if not is_ready:
315 raise RuntimeError(
316 f"YouTube cookie is missing or expired: {account_file}. Run `sau youtube login --account {request.account_name}` first."
317 )
318
319 app = YouTubeVideo(
320 request.title,
321 str(request.video_file),
322 request.tags,
323 str(account_file),
324 description=request.description,
325 thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
326 playlist=request.playlist,
327 visibility=request.visibility,
328 debug=request.debug,
329 headless=request.headless,
330 )
331 await app.main()
332 return account_file
333
334
335 async def upload_video(request: DouyinVideoUploadRequest) -> Path:
336 account_file = resolve_account_file("douyin", request.account_name)
337 is_ready = await douyin_setup(str(account_file), handle=False)
338 if not is_ready:
339 raise RuntimeError(
340 f"Douyin cookie is missing or expired: {account_file}. Run `sau douyin login --account {request.account_name}` first."
341 )
342
343 app = DouYinVideo(
344 request.title,
345 str(request.video_file),
346 request.tags,
347 request.publish_date,
348 str(account_file),
349 desc=request.description,
350 thumbnail_landscape_path=(
351 str(request.thumbnail_landscape_file) if request.thumbnail_landscape_file else None
352 ),
353 thumbnail_portrait_path=str(
354 request.thumbnail_portrait_file or request.thumbnail_file
355 ) if request.thumbnail_portrait_file or request.thumbnail_file else None,
356 productLink=request.product_link,
357 productTitle=request.product_title,
358 declaration=request.declaration,
359 publish_strategy=request.publish_strategy,
360 debug=request.debug,
361 headless=request.headless,
362 )
363 await app.douyin_upload_video()
364 return account_file
365
366
367 async def upload_note(request: DouyinNoteUploadRequest) -> Path:
368 account_file = resolve_account_file("douyin", request.account_name)
369 is_ready = await douyin_setup(str(account_file), handle=False)
370 if not is_ready:
371 raise RuntimeError(
372 f"Douyin cookie is missing or expired: {account_file}. Run `sau douyin login --account {request.account_name}` first."
373 )
374
375 app = DouYinNote(
376 image_paths=[str(path) for path in request.image_files],
377 title=request.title,
378 note=request.note,
379 tags=request.tags,
380 publish_date=request.publish_date,
381 account_file=str(account_file),
382 publish_strategy=request.publish_strategy,
383 debug=request.debug,
384 headless=request.headless,
385 bgm=request.bgm,
386 )
387 await app.douyin_upload_note()
388 return account_file
389
390
391 async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path:
392 account_file = resolve_account_file("kuaishou", request.account_name)
393 is_ready = await ks_setup(str(account_file), handle=False)
394 if not is_ready:
395 raise RuntimeError(
396 f"Kuaishou cookie is missing or expired: {account_file}. Run `sau kuaishou login --account {request.account_name}` first."
397 )
398
399 app = KSVideo(
400 title=request.title,
401 file_path=str(request.video_file),
402 desc=request.description,
403 tags=request.tags,
404 publish_date=request.publish_date,
405 account_file=str(account_file),
406 thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
407 publish_strategy=request.publish_strategy,
408 debug=request.debug,
409 headless=request.headless,
410 )
411 await app.main()
412 return account_file
413
414
415 async def upload_kuaishou_note(request: KuaishouNoteUploadRequest) -> Path:
416 account_file = resolve_account_file("kuaishou", request.account_name)
417 is_ready = await ks_setup(str(account_file), handle=False)
418 if not is_ready:
419 raise RuntimeError(
420 f"Kuaishou cookie is missing or expired: {account_file}. Run `sau kuaishou login --account {request.account_name}` first."
421 )
422
423 app = KSNote(
424 image_paths=[str(path) for path in request.image_files],
425 title=request.title,
426 note=request.note,
427 tags=request.tags,
428 publish_date=request.publish_date,
429 account_file=str(account_file),
430 publish_strategy=request.publish_strategy,
431 debug=request.debug,
432 headless=request.headless,
433 )
434 await app.main()
435 return account_file
436
437
438 async def upload_xiaohongshu_video(request: XiaohongshuVideoUploadRequest) -> Path:
439 account_file = resolve_account_file("xiaohongshu", request.account_name)
440 is_ready = await xiaohongshu_setup(str(account_file), handle=False)
441 if not is_ready:
442 raise RuntimeError(
443 f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first."
444 )
445
446 app = XiaoHongShuVideo(
447 title=request.title,
448 file_path=str(request.video_file),
449 desc=request.description,
450 tags=request.tags,
451 publish_date=request.publish_date,
452 account_file=str(account_file),
453 thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
454 publish_strategy=request.publish_strategy,
455 debug=request.debug,
456 headless=request.headless,
457 )
458 await app.main()
459 return account_file
460
461
462 async def upload_xiaohongshu_note(request: XiaohongshuNoteUploadRequest) -> Path:
463 account_file = resolve_account_file("xiaohongshu", request.account_name)
464 is_ready = await xiaohongshu_setup(str(account_file), handle=False)
465 if not is_ready:
466 raise RuntimeError(
467 f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first."
468 )
469
470 app = XiaoHongShuNote(
471 image_paths=[str(path) for path in request.image_files],
472 title=request.title,
473 desc=request.note,
474 note=request.note,
475 tags=request.tags,
476 publish_date=request.publish_date,
477 account_file=str(account_file),
478 publish_strategy=request.publish_strategy,
479 debug=request.debug,
480 headless=request.headless,
481 )
482 await app.main()
483 return account_file
484
485
486 async def upload_bilibili_video(request: BilibiliVideoUploadRequest) -> Path:
487 account_file = resolve_account_file("bilibili", request.account_name)
488 if not account_file.exists():
489 raise RuntimeError(
490 f"Bilibili account file is missing: {account_file}. Run `sau bilibili login --account {request.account_name}` first."
491 )
492
493 arguments = [
494 "-u",
495 str(account_file),
496 "upload",
497 str(request.video_file),
498 "--title",
499 request.title,
500 "--desc",
501 request.description,
502 "--tid",
503 str(request.tid),
504 ]
505 if request.tags:
506 arguments.extend(["--tag", ",".join(request.tags)])
507 if request.thumbnail_file:
508 arguments.extend(["--cover", str(request.thumbnail_file)])
509 if isinstance(request.publish_date, datetime):
510 arguments.extend(["--dtime", str(int(request.publish_date.timestamp()))])
511
512 result = run_biliup_command(arguments)
513 if result.returncode != 0:
514 raise RuntimeError((result.stderr or result.stdout or "").strip() or "Bilibili upload failed")
515 return account_file
516
517
518 async def upload_tencent_video(request: TencentVideoUploadRequest) -> Path:
519 account_file = resolve_account_file("tencent", request.account_name)
520 is_ready = await tencent_setup(str(account_file), handle=False)
521 if not is_ready:
522 raise RuntimeError(
523 f"Tencent/WeChat Channels cookie is missing or expired: {account_file}. "
524 f"Run `sau tencent login --account {request.account_name}` first."
525 )
526
527 app = TencentVideo(
528 title=request.title,
529 file_path=str(request.video_file),
530 tags=request.tags,
531 publish_date=request.publish_date,
532 account_file=str(account_file),
533 category=request.category,
534 is_draft=request.is_draft,
535 desc=request.description,
536 thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
537 thumbnail_landscape_path=(
538 str(request.thumbnail_landscape_file) if request.thumbnail_landscape_file else None
539 ),
540 thumbnail_portrait_path=(
541 str(request.thumbnail_portrait_file) if request.thumbnail_portrait_file else None
542 ),
543 short_title=request.short_title,
544 publish_strategy=request.publish_strategy,
545 debug=request.debug,
546 headless=request.headless,
547 )
548 await app.tencent_upload_video()
549 return account_file
550
551
552 def existing_file_path(value: str) -> Path:
553 path = Path(value)
554 if not path.is_file():
555 raise argparse.ArgumentTypeError(f"File not found: {value}")
556 return path
557
558
559 def schedule_value(value: str):
560 try:
561 return parse_schedule(value)
562 except ValueError as exc:
563 raise argparse.ArgumentTypeError(
564 f"Invalid schedule '{value}'. Expected format: {SCHEDULE_FORMAT}"
565 ) from exc
566
567
568 def add_runtime_flags(parser: argparse.ArgumentParser) -> None:
569 parser.add_argument("--debug", action="store_true", help="Enable debug mode")
570 headless_group = parser.add_mutually_exclusive_group()
571 headless_group.add_argument("--headed", dest="headless", action="store_false", help="Run with browser UI")
572 headless_group.add_argument("--headless", dest="headless", action="store_true", help="Run in headless mode")
573 parser.set_defaults(headless=True)
574
575
576 def build_parser() -> argparse.ArgumentParser:
577 schedule_help = SCHEDULE_FORMAT.replace("%", "%%")
578 parser = argparse.ArgumentParser(
579 prog="sau",
580 description="CLI for social-auto-upload.",
581 )
582 platform_parsers = parser.add_subparsers(dest="platform", required=True)
583
584 douyin_parser = platform_parsers.add_parser("douyin", help="Douyin operations")
585 douyin_actions = douyin_parser.add_subparsers(dest="action", required=True)
586
587 for action_name in ("login", "check"):
588 action_parser = douyin_actions.add_parser(action_name, help=f"Douyin {action_name}")
589 action_parser.add_argument("--account", required=True, help="Douyin user-defined account_name")
590 if action_name == "login":
591 add_runtime_flags(action_parser)
592
593 upload_video_parser = douyin_actions.add_parser("upload-video", help="Upload one video to Douyin")
594 upload_video_parser.add_argument("--account", required=True, help="Douyin user-defined account_name")
595 upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
596 upload_video_parser.add_argument("--title", required=True, help="Video title")
597 upload_video_parser.add_argument("--desc", default="", help="Optional video description")
598 upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
599 upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
600 upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional 3:4 portrait thumbnail path")
601 upload_video_parser.add_argument("--thumbnail-landscape", type=existing_file_path, help="Optional 4:3 landscape thumbnail path")
602 upload_video_parser.add_argument("--thumbnail-portrait", type=existing_file_path, help="Optional 3:4 portrait thumbnail path")
603 upload_video_parser.add_argument("--product-link", default="", help="Optional product link")
604 upload_video_parser.add_argument("--product-title", default="", help="Optional product title")
605 upload_video_parser.add_argument(
606 "--declaration",
607 help="Exact Douyin self-declaration option text; omitted means do not set one",
608 )
609 add_runtime_flags(upload_video_parser)
610
611 upload_note_parser = douyin_actions.add_parser("upload-note", help="Upload one note to Douyin")
612 upload_note_parser.add_argument("--account", required=True, help="Douyin user-defined account_name")
613 upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
614 upload_note_parser.add_argument("--title", required=True, help="Note title")
615 upload_note_parser.add_argument("--note", default="", help="Optional note content")
616 upload_note_parser.add_argument("--notef", default="", help="Read note content from file (txt/md)")
617 upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
618 upload_note_parser.add_argument("--bgm", default="", help="BGM music name to search and select")
619 upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
620 add_runtime_flags(upload_note_parser)
621
622 kuaishou_parser = platform_parsers.add_parser("kuaishou", help="Kuaishou operations")
623 kuaishou_actions = kuaishou_parser.add_subparsers(dest="action", required=True)
624
625 for action_name in ("login", "check"):
626 action_parser = kuaishou_actions.add_parser(action_name, help=f"Kuaishou {action_name}")
627 action_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name")
628 if action_name == "login":
629 add_runtime_flags(action_parser)
630
631 kuaishou_upload_video_parser = kuaishou_actions.add_parser("upload-video", help="Upload one video to Kuaishou")
632 kuaishou_upload_video_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name")
633 kuaishou_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
634 kuaishou_upload_video_parser.add_argument("--title", required=True, help="Video title")
635 kuaishou_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
636 kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
637 kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
638 kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
639 add_runtime_flags(kuaishou_upload_video_parser)
640
641 kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou")
642 kuaishou_upload_note_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name")
643 kuaishou_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
644 kuaishou_upload_note_parser.add_argument("--title", required=True, help="Note title")
645 kuaishou_upload_note_parser.add_argument("--note", default="", help="Optional note content")
646 kuaishou_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
647 kuaishou_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
648 add_runtime_flags(kuaishou_upload_note_parser)
649
650 xiaohongshu_parser = platform_parsers.add_parser("xiaohongshu", help="Xiaohongshu operations")
651 xiaohongshu_actions = xiaohongshu_parser.add_subparsers(dest="action", required=True)
652
653 for action_name in ("login", "check"):
654 action_parser = xiaohongshu_actions.add_parser(action_name, help=f"Xiaohongshu {action_name}")
655 action_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
656 if action_name == "login":
657 add_runtime_flags(action_parser)
658
659 xiaohongshu_upload_video_parser = xiaohongshu_actions.add_parser("upload-video", help="Upload one video to Xiaohongshu")
660 xiaohongshu_upload_video_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
661 xiaohongshu_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
662 xiaohongshu_upload_video_parser.add_argument("--title", required=True, help="Video title")
663 xiaohongshu_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
664 xiaohongshu_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
665 xiaohongshu_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
666 xiaohongshu_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
667 add_runtime_flags(xiaohongshu_upload_video_parser)
668
669 xiaohongshu_upload_note_parser = xiaohongshu_actions.add_parser("upload-note", help="Upload one note to Xiaohongshu")
670 xiaohongshu_upload_note_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
671 xiaohongshu_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
672 xiaohongshu_upload_note_parser.add_argument("--title", required=True, help="Note title")
673 xiaohongshu_upload_note_parser.add_argument("--note", default="", help="Optional note content")
674 xiaohongshu_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
675 xiaohongshu_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
676 add_runtime_flags(xiaohongshu_upload_note_parser)
677
678 bilibili_parser = platform_parsers.add_parser("bilibili", help="Bilibili operations")
679 bilibili_actions = bilibili_parser.add_subparsers(dest="action", required=True)
680
681 for action_name in ("login", "check"):
682 action_parser = bilibili_actions.add_parser(action_name, help=f"Bilibili {action_name}")
683 action_parser.add_argument("--account", required=True, help="Bilibili user-defined account_name")
684
685 bilibili_upload_video_parser = bilibili_actions.add_parser("upload-video", help="Upload one video to Bilibili")
686 bilibili_upload_video_parser.add_argument("--account", required=True, help="Bilibili user-defined account_name")
687 bilibili_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
688 bilibili_upload_video_parser.add_argument("--title", required=True, help="Video title")
689 bilibili_upload_video_parser.add_argument("--desc", required=True, help="Video description")
690 bilibili_upload_video_parser.add_argument("--tid", required=True, type=int, help="Bilibili category id")
691 bilibili_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
692 bilibili_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional Bilibili cover image path")
693 bilibili_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
694
695 tencent_parser = platform_parsers.add_parser("tencent", help="Tencent/WeChat Channels operations")
696 tencent_actions = tencent_parser.add_subparsers(dest="action", required=True)
697
698 for action_name in ("login", "check"):
699 action_parser = tencent_actions.add_parser(action_name, help=f"Tencent/WeChat Channels {action_name}")
700 action_parser.add_argument("--account", required=True, help="Tencent user-defined account_name")
701 if action_name == "login":
702 add_runtime_flags(action_parser)
703
704 tencent_upload_video_parser = tencent_actions.add_parser("upload-video", help="Upload one video to WeChat Channels")
705 tencent_upload_video_parser.add_argument("--account", required=True, help="Tencent user-defined account_name")
706 tencent_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
707 tencent_upload_video_parser.add_argument("--title", required=True, help="Video title")
708 tencent_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
709 tencent_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
710 tencent_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
711 tencent_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional 3:4 portrait thumbnail path")
712 tencent_upload_video_parser.add_argument("--thumbnail-landscape", type=existing_file_path, help="Optional 4:3 landscape thumbnail path")
713 tencent_upload_video_parser.add_argument("--thumbnail-portrait", type=existing_file_path, help="Optional 3:4 portrait thumbnail path")
714 tencent_upload_video_parser.add_argument("--short-title", help="Optional WeChat Channels short title")
715 tencent_upload_video_parser.add_argument("--category", help="Optional original content category")
716 tencent_upload_video_parser.add_argument("--draft", action="store_true", help="Save as draft instead of publishing")
717 add_runtime_flags(tencent_upload_video_parser)
718
719 youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations")
720 youtube_actions = youtube_parser.add_subparsers(dest="action", required=True)
721
722 for action_name in ("login", "check"):
723 action_parser = youtube_actions.add_parser(action_name, help=f"YouTube {action_name}")
724 action_parser.add_argument("--account", required=True, help="YouTube user-defined account_name")
725 if action_name == "login":
726 add_runtime_flags(action_parser)
727
728 youtube_upload_video_parser = youtube_actions.add_parser("upload-video", help="Upload one video to YouTube")
729 youtube_upload_video_parser.add_argument("--account", required=True, help="YouTube user-defined account_name")
730 youtube_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
731 youtube_upload_video_parser.add_argument("--title", required=True, help="Video title (<=100 chars)")
732 youtube_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
733 youtube_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
734 youtube_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail image path")
735 youtube_upload_video_parser.add_argument("--playlist", help="Optional playlist name to add the video to (for series)")
736 youtube_upload_video_parser.add_argument(
737 "--visibility", default="public", choices=["public", "unlisted", "private"], help="Video visibility")
738 add_runtime_flags(youtube_upload_video_parser)
739 return parser
740
741
742 async def dispatch(args: argparse.Namespace) -> int:
743 if args.platform == "douyin":
744 if args.action == "login":
745 result = await login_douyin_account(args.account, headless=args.headless)
746 if not result["success"]:
747 raise RuntimeError(result["message"])
748 print(f"Douyin login flow completed: {result['account_file']}")
749 return 0
750
751 if args.action == "check":
752 is_valid = await check_douyin_account(args.account)
753 print("valid" if is_valid else "invalid")
754 return 0 if is_valid else 1
755
756 publish_strategy = DOUYIN_PUBLISH_STRATEGY_SCHEDULED if args.schedule else DOUYIN_PUBLISH_STRATEGY_IMMEDIATE
757
758 if args.action == "upload-video":
759 request = DouyinVideoUploadRequest(
760 account_name=args.account,
761 video_file=args.file,
762 title=args.title,
763 description=args.desc,
764 tags=parse_tags(args.tags),
765 publish_date=args.schedule or 0,
766 thumbnail_file=args.thumbnail,
767 thumbnail_landscape_file=args.thumbnail_landscape,
768 thumbnail_portrait_file=args.thumbnail_portrait,
769 product_link=args.product_link,
770 product_title=args.product_title,
771 declaration=args.declaration,
772 publish_strategy=publish_strategy,
773 debug=args.debug,
774 headless=args.headless,
775 )
776 await upload_video(request)
777 print(f"Douyin video upload submitted: {request.video_file}")
778 return 0
779
780 if args.action == "upload-note":
781 # 如果指定了 --notef,读取文件内容作为 note
782 note_content = args.note
783 if args.notef:
784 note_file = Path(args.notef)
785 if not note_file.exists():
786 print(f"错误:文件不存在: {note_file}", file=sys.stderr)
787 return 1
788 note_content = note_file.read_text(encoding="utf-8")
789
790 request = DouyinNoteUploadRequest(
791 account_name=args.account,
792 image_files=parse_image_files(args.images),
793 title=args.title,
794 note=note_content,
795 tags=parse_tags(args.tags),
796 publish_date=args.schedule or 0,
797 publish_strategy=publish_strategy,
798 debug=args.debug,
799 headless=args.headless,
800 bgm=args.bgm or "",
801 )
802 await upload_note(request)
803 print(f"Douyin note upload submitted: {len(request.image_files)} images")
804 return 0
805
806 raise RuntimeError(f"Unsupported Douyin action: {args.action}")
807
808 if args.platform == "kuaishou":
809 if args.action == "login":
810 result = await login_kuaishou_account(args.account, headless=args.headless)
811 if not result["success"]:
812 raise RuntimeError(result["message"])
813 print(f"Kuaishou login flow completed: {result['account_file']}")
814 return 0
815
816 if args.action == "check":
817 is_valid = await check_kuaishou_account(args.account)
818 print("valid" if is_valid else "invalid")
819 return 0 if is_valid else 1
820
821 publish_strategy = KUAISHOU_PUBLISH_STRATEGY_SCHEDULED if args.schedule else KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE
822
823 if args.action == "upload-video":
824 request = KuaishouVideoUploadRequest(
825 account_name=args.account,
826 video_file=args.file,
827 title=args.title,
828 description=args.desc,
829 tags=parse_tags(args.tags),
830 publish_date=args.schedule or 0,
831 thumbnail_file=args.thumbnail,
832 publish_strategy=publish_strategy,
833 debug=args.debug,
834 headless=args.headless,
835 )
836 await upload_kuaishou_video(request)
837 print(f"Kuaishou video upload submitted: {request.video_file}")
838 return 0
839
840 if args.action == "upload-note":
841 request = KuaishouNoteUploadRequest(
842 account_name=args.account,
843 image_files=parse_image_files(args.images),
844 title=args.title,
845 note=args.note,
846 tags=parse_tags(args.tags),
847 publish_date=args.schedule or 0,
848 publish_strategy=publish_strategy,
849 debug=args.debug,
850 headless=args.headless,
851 )
852 await upload_kuaishou_note(request)
853 print(f"Kuaishou note upload submitted: {len(request.image_files)} images")
854 return 0
855
856 raise RuntimeError(f"Unsupported Kuaishou action: {args.action}")
857
858 if args.platform == "xiaohongshu":
859 if args.action == "login":
860 result = await login_xiaohongshu_account(args.account, headless=args.headless)
861 if not result["success"]:
862 raise RuntimeError(result["message"])
863 print(f"Xiaohongshu login flow completed: {result['account_file']}")
864 return 0
865
866 if args.action == "check":
867 is_valid = await check_xiaohongshu_account(args.account)
868 print("valid" if is_valid else "invalid")
869 return 0 if is_valid else 1
870
871 publish_strategy = (
872 XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED if args.schedule else XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
873 )
874
875 if args.action == "upload-video":
876 parsed_tags = parse_tags(args.tags)
877 if len(parsed_tags) > 10:
878 print(f"错误:小红书标签最多 10 个,当前提供了 {len(parsed_tags)} 个: {parsed_tags}", file=sys.stderr)
879 return 1
880 request = XiaohongshuVideoUploadRequest(
881 account_name=args.account,
882 video_file=args.file,
883 title=args.title,
884 description=args.desc,
885 tags=parsed_tags,
886 publish_date=args.schedule or 0,
887 thumbnail_file=args.thumbnail,
888 publish_strategy=publish_strategy,
889 debug=args.debug,
890 headless=args.headless,
891 )
892 await upload_xiaohongshu_video(request)
893 print(f"Xiaohongshu video upload submitted: {request.video_file}")
894 return 0
895
896 if args.action == "upload-note":
897 parsed_tags = parse_tags(args.tags)
898 if len(parsed_tags) > 10:
899 print(f"错误:小红书标签最多 10 个,当前提供了 {len(parsed_tags)} 个: {parsed_tags}", file=sys.stderr)
900 return 1
901 request = XiaohongshuNoteUploadRequest(
902 account_name=args.account,
903 image_files=parse_image_files(args.images),
904 title=args.title,
905 note=args.note,
906 tags=parsed_tags,
907 publish_date=args.schedule or 0,
908 publish_strategy=publish_strategy,
909 debug=args.debug,
910 headless=args.headless,
911 )
912 await upload_xiaohongshu_note(request)
913 print(f"Xiaohongshu note upload submitted: {len(request.image_files)} images")
914 return 0
915
916 raise RuntimeError(f"Unsupported Xiaohongshu action: {args.action}")
917
918 if args.platform == "bilibili":
919 if args.action == "login":
920 result = await login_bilibili_account(args.account)
921 if not result["success"]:
922 raise RuntimeError(result["message"])
923 print(f"Bilibili login flow completed: {result['account_file']}")
924 return 0
925
926 if args.action == "check":
927 is_valid = await check_bilibili_account(args.account)
928 print("valid" if is_valid else "invalid")
929 return 0 if is_valid else 1
930
931 if args.action == "upload-video":
932 request = BilibiliVideoUploadRequest(
933 account_name=args.account,
934 video_file=args.file,
935 title=args.title,
936 description=args.desc,
937 tid=args.tid,
938 tags=parse_tags(args.tags),
939 publish_date=args.schedule or 0,
940 thumbnail_file=args.thumbnail,
941 )
942 await upload_bilibili_video(request)
943 print(f"Bilibili video upload submitted: {request.video_file}")
944 return 0
945
946 raise RuntimeError(f"Unsupported Bilibili action: {args.action}")
947
948 if args.platform == "tencent":
949 if args.action == "login":
950 result = await login_tencent_account(args.account, headless=args.headless)
951 if not result["success"]:
952 raise RuntimeError(result["message"])
953 print(f"Tencent/WeChat Channels login flow completed: {result['account_file']}")
954 return 0
955
956 if args.action == "check":
957 is_valid = await check_tencent_account(args.account)
958 print("valid" if is_valid else "invalid")
959 return 0 if is_valid else 1
960
961 publish_strategy = TENCENT_PUBLISH_STRATEGY_SCHEDULED if args.schedule else TENCENT_PUBLISH_STRATEGY_IMMEDIATE
962
963 if args.action == "upload-video":
964 request = TencentVideoUploadRequest(
965 account_name=args.account,
966 video_file=args.file,
967 title=args.title,
968 description=args.desc,
969 tags=parse_tags(args.tags),
970 publish_date=args.schedule or 0,
971 thumbnail_file=args.thumbnail,
972 thumbnail_landscape_file=args.thumbnail_landscape,
973 thumbnail_portrait_file=args.thumbnail_portrait,
974 short_title=args.short_title,
975 category=args.category,
976 is_draft=args.draft,
977 publish_strategy=publish_strategy,
978 debug=args.debug,
979 headless=args.headless,
980 )
981 await upload_tencent_video(request)
982 print(f"Tencent/WeChat Channels video upload submitted: {request.video_file}")
983 return 0
984
985 raise RuntimeError(f"Unsupported Tencent/WeChat Channels action: {args.action}")
986
987 if args.platform == "youtube":
988 if args.action == "login":
989 result = await login_youtube_account(args.account, headless=args.headless)
990 if not result["success"]:
991 raise RuntimeError(result["message"])
992 print(f"YouTube login flow completed: {result['account_file']}")
993 return 0
994
995 if args.action == "check":
996 is_valid = await check_youtube_account(args.account)
997 print("valid" if is_valid else "invalid")
998 return 0 if is_valid else 1
999
1000 if args.action == "upload-video":
1001 request = YouTubeVideoUploadRequest(
1002 account_name=args.account,
1003 video_file=args.file,
1004 title=args.title,
1005 description=args.desc,
1006 tags=parse_tags(args.tags),
1007 thumbnail_file=args.thumbnail,
1008 playlist=args.playlist,
1009 visibility=args.visibility,
1010 debug=args.debug,
1011 headless=args.headless,
1012 )
1013 await upload_youtube_video(request)
1014 print(f"YouTube video upload submitted: {request.video_file}")
1015 return 0
1016
1017 raise RuntimeError(f"Unsupported YouTube action: {args.action}")
1018
1019 raise RuntimeError(f"Unsupported platform: {args.platform}")
1020
1021
1022 def main(argv: Sequence[str] | None = None) -> int:
1023 parser = build_parser()
1024 args = parser.parse_args(list(argv) if argv is not None else None)
1025 try:
1026 return asyncio.run(dispatch(args))
1027 except Exception as exc:
1028 print(str(exc), file=sys.stderr)
1029 return 1
1030
1031
1032 if __name__ == "__main__":
1033 raise SystemExit(main())
1034
1034 lines PYTHON