diff --git a/README.md b/README.md index 7616616..409a98f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ | 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前主线重构最完整 | | Bilibili | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 依赖 `biliup` | | 小红书(浏览器版) | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 当前仓库有浏览器 uploader | -| 快手 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 | +| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 初版已接入 | | 视频号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 对应 `tencent_uploader` | | 百家号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 | | TikTok | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 当前示例走 Chrome 版实现 | @@ -48,6 +48,7 @@ - CLI 使用请看:[CLI 使用说明](./docs/CLI.md) - agent / skill 请看:[Douyin Upload Skill](./skills/douyin-upload/SKILL.md) +- agent / skill 请看:[Kuaishou Upload Skill](./skills/kuaishou-upload/SKILL.md) - 历史 Web 说明请看:[历史 Web 版本说明](./docs/legacy-web.md) - 其他单平台 skill 与整合型 skill 仍在开发中 - `requirements.txt` 目前主要用于历史兼容路径,普通用户不需要优先使用它 @@ -91,12 +92,18 @@ Web 端相关代码仍然保留,但已经不是当前主线,不保证可直 ### 方式 1:使用 CLI -当前只有抖音已经完成 CLI 化: +当前抖音和快手已经完成 CLI 化: ```bash sau douyin login --account creator sau douyin check --account creator sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例标题" +sau douyin upload-note --account creator --images videos/1.png videos/2.png --note "图文示例" + +sau kuaishou login --account creator +sau kuaishou check --account creator +sau kuaishou upload-video --account creator --file videos/demo.mp4 --title "示例标题" +sau kuaishou upload-note --account creator --images videos/1.png videos/2.png videos/3.png --note "图文示例" ``` ### 方式 2:使用 examples @@ -105,7 +112,7 @@ sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例 - `examples/upload_to_douyin.py` - `examples/upload_video_to_bilibili.py` -- `examples/upload_video_to_kuaishou.py` +- `examples/upload_to_kuaishou.py` - `examples/upload_video_to_tencent.py` - `examples/upload_video_to_baijiahao.py` - `examples/upload_video_to_tiktok.py` @@ -202,4 +209,4 @@ sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例 [![Star History Chart](https://api.star-history.com/svg?repos=dreammis/social-auto-upload&type=Date)](https://star-history.com/#dreammis/social-auto-upload&Date) ## Community -LINUX DO - The New Ideal Community \ No newline at end of file +LINUX DO - The New Ideal Community diff --git a/docs/CLI.md b/docs/CLI.md index 575c8a7..7472be2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,12 +1,17 @@ # CLI 使用说明 -项目现在提供一个更直接的抖音 CLI 入口,默认推荐使用 `sau`。 +项目现在提供一个统一的 CLI 入口 `sau`,当前主线已经接入: + +- `douyin` +- `kuaishou` 实现说明: - `sau_cli.py` 是当前 CLI 的主入口和唯一主要实现文件 - `sau.exe` 是安装后在 Windows 虚拟环境里自动生成的命令入口,本质上还是调用 `sau_cli.py` -- 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill:`skills/douyin-upload/` +- 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill: + - `skills/douyin-upload/` + - `skills/kuaishou-upload/` ## 安装 CLI 入口 @@ -20,6 +25,7 @@ uv pip install -e . ```bash sau douyin --help +sau kuaishou --help ``` ## 安装 patchright 浏览器 @@ -40,13 +46,32 @@ sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例 sau douyin upload-note --account creator --images videos/1.png videos/2.png --note "图文示例" --tags 图文,测试 ``` +## 快手 CLI 子命令 + +```bash +sau kuaishou login --account creator +sau kuaishou login --account creator --headed +sau kuaishou check --account creator +sau kuaishou upload-video --account creator --file videos/demo.mp4 --title "示例标题" --tags 运动,训练 +sau kuaishou upload-note --account creator --images videos/1.png videos/2.png videos/3.png --note "图文示例" --tags 图文,测试 +``` + +## 登录二维码说明 + +- 抖音和快手登录过程中,CLI / uploader 可能会生成临时二维码图片 +- 对普通用户来说,可以直接打开该图片扫码 +- 对可操作本地文件的 agent 来说,不要只把图片路径告诉用户 +- 这类二维码图片本身就是给用户扫码的,agent 应优先直接展示/发送本地图片给用户 + ## 定时发布 -视频和图文都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到 `scheduled` 发布策略;不传则默认立即发布。 +抖音和快手的视频、图文都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到 `scheduled` 发布策略;不传则默认立即发布。 ```bash sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30" sau douyin upload-note --account creator --images videos/1.png videos/2.png --note "图文示例" --schedule "2026-03-24 21:30" +sau kuaishou upload-video --account creator --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30" +sau kuaishou upload-note --account creator --images videos/1.png videos/2.png videos/3.png --note "图文示例" --schedule "2026-03-24 21:30" ``` ## 运行时参数 @@ -72,6 +97,11 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度: --title "示例标题" --tags 运动,训练 --thumbnail videos/demo.png +``` + +抖音额外支持: + +```bash --product-link https://example.com/item --product-title 示例商品 ``` @@ -86,7 +116,7 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度: 图文上传当前限制: -- 最多 35 张图片 -- 不支持 GIF +- 抖音:最多 35 张图片,不支持 GIF +- 快手:支持多张图片,建议传真实不同文件,不要把同一路径重复多次 后续维护 CLI 时,优先看 `sau_cli.py` 和 `uploader/`。 diff --git a/docs/install.md b/docs/install.md index ae23ace..5098191 100644 --- a/docs/install.md +++ b/docs/install.md @@ -83,6 +83,7 @@ Windows 也可以直接手动复制并重命名。 ```bash sau --help sau douyin --help +sau kuaishou --help ``` 如果命令找不到,优先确认: @@ -98,6 +99,15 @@ sau douyin check --account creator sau douyin upload-video --account creator --file videos/demo.mp4 --title "示例标题" ``` +### 8. 快手主线示例 + +```bash +sau kuaishou login --account creator --headed +sau kuaishou check --account creator +sau kuaishou upload-video --account creator --file videos/demo.mp4 --title "示例标题" +sau kuaishou upload-note --account creator --images videos/1.png videos/2.png videos/3.png --note "图文示例" +``` + ## For AI Agents 如果你是一个可执行命令的 agent,请优先按下面顺序处理: @@ -129,24 +139,35 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i ```bash sau --help sau douyin --help +sau kuaishou --help ``` -6. 如果用户的目标是抖音登录、cookie 校验、视频上传、图文上传,优先走 CLI: +6. 如果用户的目标是抖音或快手的登录、cookie 校验、视频上传、图文上传,优先走 CLI: ```bash sau douyin login sau douyin check sau douyin upload-video sau douyin upload-note + +sau kuaishou login +sau kuaishou check +sau kuaishou upload-video +sau kuaishou upload-note ``` 7. 如果用户明确在使用 skill 系统,再引导其阅读: - `skills/douyin-upload/SKILL.md` - `skills/douyin-upload/references/cli-contract.md` +- `skills/kuaishou-upload/SKILL.md` +- `skills/kuaishou-upload/references/cli-contract.md` ### 对 agent 的额外说明 +- 当登录流程生成本地二维码图片时,不要只把图片路径发给用户 +- 这类二维码图片本身就是给用户扫码的,agent 应优先直接展示/发送本地图片给用户扫码 +- 如果环境支持查看本地图片,优先用查看图片能力把二维码展示出来;路径只作为补充信息 - `requirements.txt` 目前是历史兼容文件,不是主安装入口 - `uploader/` 是核心实现目录 - `sau_cli.py` 是当前 CLI 主入口 diff --git a/docs/update.md b/docs/update.md index a3d6034..1ef4024 100644 --- a/docs/update.md +++ b/docs/update.md @@ -46,12 +46,15 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i ```bash sau --help sau douyin --help +sau kuaishou --help sau douyin check --account your_account +sau kuaishou check --account your_account ``` 如果你在使用 skill,也建议同步看一下: - `skills/douyin-upload/SKILL.md` +- `skills/kuaishou-upload/SKILL.md` - `docs/CLI.md` ## For AI Agents @@ -77,12 +80,14 @@ uv pip install -e . ```bash sau --help sau douyin --help +sau kuaishou --help ``` -5. 如果任务是抖音链路,再继续验证: +5. 如果任务是抖音或快手链路,再继续验证: ```bash sau douyin check --account test +sau kuaishou check --account test ``` 6. 如果用户依赖 skill,再检查这些路径是否仍存在且契约没变: @@ -90,9 +95,13 @@ sau douyin check --account test - `skills/douyin-upload/SKILL.md` - `skills/douyin-upload/references/cli-contract.md` - `skills/douyin-upload/references/runtime-requirements.md` +- `skills/kuaishou-upload/SKILL.md` +- `skills/kuaishou-upload/references/cli-contract.md` +- `skills/kuaishou-upload/references/runtime-requirements.md` ### 对 agent 的额外说明 - 优先相信 `pyproject.toml`,不要把 `requirements.txt` 视为当前主线真相 - 当前 README 只做总览,安装和更新以 `docs/install.md`、`docs/update.md` 为准 - Web 相关内容属于历史路径,见 `docs/legacy-web.md` +- 如果更新后的登录流程输出了本地二维码图片,agent 应直接把图片展示/发送给用户扫码,不要只回传图片路径 diff --git a/examples/get_kuaishou_cookie.py b/examples/get_kuaishou_cookie.py index 9301ae4..86b7b1e 100644 --- a/examples/get_kuaishou_cookie.py +++ b/examples/get_kuaishou_cookie.py @@ -4,7 +4,12 @@ from pathlib import Path from conf import BASE_DIR from uploader.ks_uploader.main import ks_setup -if __name__ == '__main__': - account_file = Path(BASE_DIR / "cookies" / "ks_uploader" / "account.json") + +def login_to_kuaishou(): + account_file = Path(BASE_DIR / "cookies" / "kuaishou_creator1.json") account_file.parent.mkdir(exist_ok=True) - cookie_setup = asyncio.run(ks_setup(str(account_file), handle=True)) + asyncio.run(ks_setup(str(account_file), handle=True)) + + +if __name__ == '__main__': + login_to_kuaishou() diff --git a/examples/upload_to_douyin.py b/examples/upload_to_douyin.py index 10d7dfe..ec30079 100644 --- a/examples/upload_to_douyin.py +++ b/examples/upload_to_douyin.py @@ -40,7 +40,6 @@ def upload_note_to_douyin(): tags = ["图文", "示例", "抖音图文"] video_meta_publish_time = datetime.strptime("2026-3-25 12:13", "%Y-%m-%d %H:%M") - app = DouYinNote( image_paths=image_paths, note=note, diff --git a/examples/upload_to_kuaishou.py b/examples/upload_to_kuaishou.py new file mode 100644 index 0000000..8d458e1 --- /dev/null +++ b/examples/upload_to_kuaishou.py @@ -0,0 +1,64 @@ +import asyncio +from datetime import datetime +from pathlib import Path + +from conf import BASE_DIR +from uploader.ks_uploader.main import ( + KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE, + KUAISHOU_PUBLISH_STRATEGY_SCHEDULED, + KSNote, + KSVideo, +) + + +def upload_video_to_kuaishou(): + account_file = Path(BASE_DIR / "cookies" / "kuaishou_creator.json") + account_file.parent.mkdir(exist_ok=True) + + video_file_path = Path(BASE_DIR) / "videos/demo.mp4" + thumbnail_path = video_file_path.with_suffix(".png") + video_meta_title = "快手视频上传示例" + video_meta_hashtags = ["快手", "自动上传", "示例"] + video_meta_publish_time = datetime.strptime("2026-03-27 12:13", "%Y-%m-%d %H:%M") + publish_strategy = KUAISHOU_PUBLISH_STRATEGY_SCHEDULED + + app = KSVideo( + title=video_meta_title, + file_path=video_file_path, + tags=video_meta_hashtags, + publish_date=video_meta_publish_time, + account_file=account_file, + thumbnail_path=thumbnail_path if thumbnail_path.exists() else None, + publish_strategy=publish_strategy, + ) + asyncio.run(app.main()) + + +def upload_note_to_kuaishou(): + account_file = Path(BASE_DIR / "cookies" / "kuaishou_creator1.json") + account_file.parent.mkdir(exist_ok=True) + + image_paths = [ + Path(BASE_DIR) / "videos/demo.png", + Path(BASE_DIR) / "videos/demo1.png", + Path(BASE_DIR) / "videos/demo2.png", + ] + note = "快手图文内容示例" + tags = ["快手图文", "自动上传", "示例"] + note_meta_publish_time = datetime.strptime("2026-03-27 12:13", "%Y-%m-%d %H:%M") + publish_strategy = KUAISHOU_PUBLISH_STRATEGY_SCHEDULED + + app = KSNote( + image_paths=image_paths, + note=note, + tags=tags, + publish_date=note_meta_publish_time, + account_file=account_file, + publish_strategy=publish_strategy, + ) + asyncio.run(app.main()) + + +if __name__ == '__main__': + upload_video_to_kuaishou() + # upload_note_to_kuaishou() diff --git a/examples/upload_video_to_kuaishou.py b/examples/upload_video_to_kuaishou.py deleted file mode 100644 index 8211bca..0000000 --- a/examples/upload_video_to_kuaishou.py +++ /dev/null @@ -1,26 +0,0 @@ -import asyncio -from pathlib import Path - -from conf import BASE_DIR -from uploader.ks_uploader.main import ks_setup, KSVideo -from utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags - - -if __name__ == '__main__': - filepath = Path(BASE_DIR) / "videos" - account_file = Path(BASE_DIR / "cookies" / "ks_uploader" / "account.json") - # 获取视频目录 - folder_path = Path(filepath) - # 获取文件夹中的所有文件 - files = list(folder_path.glob("*.mp4")) - file_num = len(files) - publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16]) - cookie_setup = asyncio.run(ks_setup(account_file, handle=False)) - for index, file in enumerate(files): - title, tags = get_title_and_hashtags(str(file)) - # 打印视频文件名、标题和 hashtag - print(f"视频文件名:{file}") - print(f"标题:{title}") - print(f"Hashtag:{tags}") - app = KSVideo(title, file, tags, publish_datetimes[index], account_file) - asyncio.run(app.main(), debug=False) diff --git a/sau_cli.py b/sau_cli.py index 5efca11..0dc237e 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -14,9 +14,17 @@ from uploader.douyin_uploader.main import ( DOUYIN_PUBLISH_STRATEGY_SCHEDULED, DouYinNote, DouYinVideo, - cookie_auth, + cookie_auth as douyin_cookie_auth, douyin_setup, ) +from uploader.ks_uploader.main import ( + KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE, + KUAISHOU_PUBLISH_STRATEGY_SCHEDULED, + KSNote, + KSVideo, + cookie_auth as kuaishou_cookie_auth, + ks_setup, +) SCHEDULE_FORMAT = "%Y-%m-%d %H:%M" @@ -48,12 +56,37 @@ class DouyinNoteUploadRequest: headless: bool = True +@dataclass(slots=True) +class KuaishouVideoUploadRequest: + account_name: str + video_file: Path + title: str + tags: list[str] + publish_date: datetime | int + thumbnail_file: Path | None = None + publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE + debug: bool = True + headless: bool = True + + +@dataclass(slots=True) +class KuaishouNoteUploadRequest: + account_name: str + image_files: list[Path] + note: str + tags: list[str] + publish_date: datetime | int + publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE + debug: bool = True + headless: bool = True + + def resolve_runtime_home() -> Path: return Path(BASE_DIR) -def resolve_account_file(account_name: str) -> Path: - account_file = resolve_runtime_home() / "cookies" / f"douyin_{account_name}.json" +def resolve_account_file(platform: str, account_name: str) -> Path: + account_file = resolve_runtime_home() / "cookies" / f"{platform}_{account_name}.json" account_file.parent.mkdir(exist_ok=True) return account_file @@ -80,20 +113,32 @@ def parse_schedule(raw_schedule: str | None) -> datetime | int: return datetime.strptime(raw_schedule, SCHEDULE_FORMAT) -async def login_account(account_name: str, headless: bool = True) -> dict: - account_file = resolve_account_file(account_name) +async def login_douyin_account(account_name: str, headless: bool = True) -> dict: + account_file = resolve_account_file("douyin", account_name) return await douyin_setup(str(account_file), handle=True, return_detail=True, headless=headless) -async def check_account(account_name: str) -> bool: - account_file = resolve_account_file(account_name) +async def check_douyin_account(account_name: str) -> bool: + account_file = resolve_account_file("douyin", account_name) if not account_file.exists(): return False - return await cookie_auth(str(account_file)) + return await douyin_cookie_auth(str(account_file)) + + +async def login_kuaishou_account(account_name: str, headless: bool = True) -> bool: + account_file = resolve_account_file("kuaishou", account_name) + return await ks_setup(str(account_file), handle=True, headless=headless) + + +async def check_kuaishou_account(account_name: str) -> bool: + account_file = resolve_account_file("kuaishou", account_name) + if not account_file.exists(): + return False + return await kuaishou_cookie_auth(str(account_file)) async def upload_video(request: DouyinVideoUploadRequest) -> Path: - account_file = resolve_account_file(request.account_name) + account_file = resolve_account_file("douyin", request.account_name) is_ready = await douyin_setup(str(account_file), handle=False) if not is_ready: raise RuntimeError( @@ -118,7 +163,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path: async def upload_note(request: DouyinNoteUploadRequest) -> Path: - account_file = resolve_account_file(request.account_name) + account_file = resolve_account_file("douyin", request.account_name) is_ready = await douyin_setup(str(account_file), handle=False) if not is_ready: raise RuntimeError( @@ -139,6 +184,51 @@ async def upload_note(request: DouyinNoteUploadRequest) -> Path: return account_file +async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path: + account_file = resolve_account_file("kuaishou", request.account_name) + is_ready = await ks_setup(str(account_file), handle=False) + if not is_ready: + raise RuntimeError( + f"Kuaishou cookie is missing or expired: {account_file}. Run `sau kuaishou login --account {request.account_name}` first." + ) + + app = KSVideo( + title=request.title, + file_path=str(request.video_file), + tags=request.tags, + publish_date=request.publish_date, + account_file=str(account_file), + thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None, + publish_strategy=request.publish_strategy, + debug=request.debug, + headless=request.headless, + ) + await app.main() + return account_file + + +async def upload_kuaishou_note(request: KuaishouNoteUploadRequest) -> Path: + account_file = resolve_account_file("kuaishou", request.account_name) + is_ready = await ks_setup(str(account_file), handle=False) + if not is_ready: + raise RuntimeError( + f"Kuaishou cookie is missing or expired: {account_file}. Run `sau kuaishou login --account {request.account_name}` first." + ) + + app = KSNote( + image_paths=[str(path) for path in request.image_files], + note=request.note, + tags=request.tags, + publish_date=request.publish_date, + account_file=str(account_file), + publish_strategy=request.publish_strategy, + debug=request.debug, + headless=request.headless, + ) + await app.main() + return account_file + + def existing_file_path(value: str) -> Path: path = Path(value) if not path.is_file(): @@ -198,61 +288,135 @@ def build_parser() -> argparse.ArgumentParser: upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") add_runtime_flags(upload_note_parser) + + kuaishou_parser = platform_parsers.add_parser("kuaishou", help="Kuaishou operations") + kuaishou_actions = kuaishou_parser.add_subparsers(dest="action", required=True) + + for action_name in ("login", "check"): + action_parser = kuaishou_actions.add_parser(action_name, help=f"Kuaishou {action_name}") + action_parser.add_argument("--account", required=True, help="Kuaishou account alias") + if action_name == "login": + add_runtime_flags(action_parser) + + kuaishou_upload_video_parser = kuaishou_actions.add_parser("upload-video", help="Upload one video to Kuaishou") + kuaishou_upload_video_parser.add_argument("--account", required=True, help="Kuaishou account alias") + kuaishou_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path") + kuaishou_upload_video_parser.add_argument("--title", required=True, help="Video title") + kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") + kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") + kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") + add_runtime_flags(kuaishou_upload_video_parser) + + kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou") + kuaishou_upload_note_parser.add_argument("--account", required=True, help="Kuaishou account alias") + kuaishou_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths") + kuaishou_upload_note_parser.add_argument("--note", required=True, help="Note content") + kuaishou_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") + kuaishou_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") + add_runtime_flags(kuaishou_upload_note_parser) return parser async def dispatch(args: argparse.Namespace) -> int: - if args.platform != "douyin": - raise RuntimeError(f"Unsupported platform: {args.platform}") + if args.platform == "douyin": + if args.action == "login": + result = await login_douyin_account(args.account, headless=args.headless) + if not result["success"]: + raise RuntimeError(result["message"]) + print(f"Douyin login flow completed: {result['account_file']}") + return 0 - if args.action == "login": - result = await login_account(args.account, headless=args.headless) - if not result["success"]: - raise RuntimeError(result["message"]) - print(f"Douyin login flow completed: {result['account_file']}") - return 0 + if args.action == "check": + is_valid = await check_douyin_account(args.account) + print("valid" if is_valid else "invalid") + return 0 if is_valid else 1 - if args.action == "check": - is_valid = await check_account(args.account) - print("valid" if is_valid else "invalid") - return 0 if is_valid else 1 + publish_strategy = DOUYIN_PUBLISH_STRATEGY_SCHEDULED if args.schedule else DOUYIN_PUBLISH_STRATEGY_IMMEDIATE - publish_strategy = DOUYIN_PUBLISH_STRATEGY_SCHEDULED if args.schedule else DOUYIN_PUBLISH_STRATEGY_IMMEDIATE + if args.action == "upload-video": + request = DouyinVideoUploadRequest( + account_name=args.account, + video_file=args.file, + title=args.title, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + thumbnail_file=args.thumbnail, + product_link=args.product_link, + product_title=args.product_title, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_video(request) + print(f"Douyin video upload submitted: {request.video_file}") + return 0 - if args.action == "upload-video": - request = DouyinVideoUploadRequest( - account_name=args.account, - video_file=args.file, - title=args.title, - tags=parse_tags(args.tags), - publish_date=args.schedule or 0, - thumbnail_file=args.thumbnail, - product_link=args.product_link, - product_title=args.product_title, - publish_strategy=publish_strategy, - debug=args.debug, - headless=args.headless, - ) - await upload_video(request) - print(f"Douyin video upload submitted: {request.video_file}") - return 0 + if args.action == "upload-note": + request = DouyinNoteUploadRequest( + account_name=args.account, + image_files=parse_image_files(args.images), + note=args.note, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_note(request) + print(f"Douyin note upload submitted: {len(request.image_files)} images") + return 0 - if args.action == "upload-note": - request = DouyinNoteUploadRequest( - account_name=args.account, - image_files=parse_image_files(args.images), - note=args.note, - tags=parse_tags(args.tags), - publish_date=args.schedule or 0, - publish_strategy=publish_strategy, - debug=args.debug, - headless=args.headless, - ) - await upload_note(request) - print(f"Douyin note upload submitted: {len(request.image_files)} images") - return 0 + raise RuntimeError(f"Unsupported Douyin action: {args.action}") - raise RuntimeError(f"Unsupported Douyin action: {args.action}") + if args.platform == "kuaishou": + if args.action == "login": + success = await login_kuaishou_account(args.account, headless=args.headless) + if not success: + raise RuntimeError("Kuaishou login flow failed") + print(f"Kuaishou login flow completed: {resolve_account_file('kuaishou', args.account)}") + return 0 + + if args.action == "check": + is_valid = await check_kuaishou_account(args.account) + print("valid" if is_valid else "invalid") + return 0 if is_valid else 1 + + publish_strategy = KUAISHOU_PUBLISH_STRATEGY_SCHEDULED if args.schedule else KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE + + if args.action == "upload-video": + request = KuaishouVideoUploadRequest( + account_name=args.account, + video_file=args.file, + title=args.title, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + thumbnail_file=args.thumbnail, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_kuaishou_video(request) + print(f"Kuaishou video upload submitted: {request.video_file}") + return 0 + + if args.action == "upload-note": + request = KuaishouNoteUploadRequest( + account_name=args.account, + image_files=parse_image_files(args.images), + note=args.note, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_kuaishou_note(request) + print(f"Kuaishou note upload submitted: {len(request.image_files)} images") + return 0 + + raise RuntimeError(f"Unsupported Kuaishou action: {args.action}") + + raise RuntimeError(f"Unsupported platform: {args.platform}") def main(argv: Sequence[str] | None = None) -> int: diff --git a/skills/douyin-upload/SKILL.md b/skills/douyin-upload/SKILL.md index 9478efd..f277d5f 100644 --- a/skills/douyin-upload/SKILL.md +++ b/skills/douyin-upload/SKILL.md @@ -47,6 +47,8 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成抖音登录、c - 如果 `sau` 不可用,按 `references/runtime-requirements.md` 里的回退方式处理 - 当用户明确指定无头或有头模式时,显式传 `--headless` 或 `--headed` - 只有用户明确要求定时发布时,才使用 `--schedule` +- 如果登录流程生成了本地二维码图片,不要只把图片路径告诉用户 +- 二维码图片本身就是给用户扫码的,优先直接把本地图片展示/发送给用户 ## 模板文件 diff --git a/skills/douyin-upload/references/cli-contract.md b/skills/douyin-upload/references/cli-contract.md index 57bf36a..4cb2c0f 100644 --- a/skills/douyin-upload/references/cli-contract.md +++ b/skills/douyin-upload/references/cli-contract.md @@ -14,6 +14,7 @@ sau douyin login --account - `--account` - 作用: - 启动抖音登录流程,为指定账号生成或刷新 cookie 文件 + - 如果登录过程中生成本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,而不是只回传路径 ### 校验 cookie diff --git a/skills/douyin-upload/references/runtime-requirements.md b/skills/douyin-upload/references/runtime-requirements.md index 645274b..9fc017a 100644 --- a/skills/douyin-upload/references/runtime-requirements.md +++ b/skills/douyin-upload/references/runtime-requirements.md @@ -64,3 +64,4 @@ uv run sau douyin --help - 使用 `--headless` 表示无头模式 - 使用 `--headed` 表示有头模式 - 如果用户明确要求无头登录,也要预期 CLI 会通过控制台输出或临时图片路径提供二维码相关提示 +- 如果登录过程中已经生成了本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径 diff --git a/skills/douyin-upload/references/troubleshooting.md b/skills/douyin-upload/references/troubleshooting.md index 6fa3791..8e9f3b1 100644 --- a/skills/douyin-upload/references/troubleshooting.md +++ b/skills/douyin-upload/references/troubleshooting.md @@ -41,8 +41,9 @@ sau douyin login --account 如果用户无法使用终端二维码输出: -- 查找 CLI 打印出来的临时二维码图片路径 -- 让用户直接用抖音 APP 扫描该图片 +- 查找 CLI 打印出来的临时二维码图片 +- agent 不要只把图片路径回给用户 +- agent 应优先直接把本地二维码图片展示/发送给用户扫码 如果终端二维码显示不正常,优先使用保存下来的图片路径,而不是反复尝试随机的终端设置。 diff --git a/skills/kuaishou-upload/SKILL.md b/skills/kuaishou-upload/SKILL.md new file mode 100644 index 0000000..151a0fa --- /dev/null +++ b/skills/kuaishou-upload/SKILL.md @@ -0,0 +1,65 @@ +--- +name: kuaishou-upload +description: 当 agent 需要通过已安装的 `sau` CLI 完成快手登录、cookie 校验、视频上传或图文发布时使用这个 skill。该 skill 适用于已经安装 `social-auto-upload` 且可调用 `sau` 命令的环境。优先使用这个 skill 进行稳定的命令式快手工作流,而不是一开始就阅读 uploader 源码。 +--- + +# 快手上传 Skill + +优先把 `sau` 作为主接口。 + +不要假设当前环境一定能读取仓库源码。 +不要一开始就去读 `uploader/`。 +只有在命令不可用或 CLI 执行失败时,才回退到故障排查说明。 + +## 功能概览 + +| 功能 | 命令入口 | 说明 | +| --- | --- | --- | +| 快手登录 | `sau kuaishou login --account ` | 生成或刷新指定账号的 cookie | +| cookie 校验 | `sau kuaishou check --account ` | 检查指定账号 cookie 是否有效 | +| 视频上传 | `sau kuaishou upload-video ...` | 上传并发布快手视频 | +| 图文上传 | `sau kuaishou upload-note ...` | 上传并发布快手图文 | + +## 默认工作流 + +1. 先确认 `references/runtime-requirements.md` 里的运行前提。 +2. 再确认 `references/cli-contract.md` 里的命令契约。 +3. 执行匹配的 `sau kuaishou ...` 命令。 +4. 如果命令失败,再看 `references/troubleshooting.md`。 + +## 支持动作 + +- 使用 `sau kuaishou login --account ` 登录快手 +- 使用 `sau kuaishou check --account ` 校验 cookie 是否有效 +- 使用 `sau kuaishou upload-video ...` 上传快手视频 +- 使用 `sau kuaishou upload-note ...` 上传快手图文 + +## 命令选择建议 + +- 当用户需要新的 cookie,或现有 cookie 已失效时,使用 `login` +- 当用户只需要确认 cookie 状态时,使用 `check` +- 当用户要发布视频时,使用 `upload-video` +- 当用户要发布图文时,使用 `upload-note` + +## 执行前检查 + +- 先确认当前 shell 里是否可以调用 `sau` +- 如果 `sau` 不可用,按 `references/runtime-requirements.md` 里的回退方式处理 +- 当用户明确指定无头或有头模式时,显式传 `--headless` 或 `--headed` +- 只有用户明确要求定时发布时,才使用 `--schedule` +- 如果登录流程生成了本地二维码图片,不要只把图片路径告诉用户 +- 二维码图片本身就是给用户扫码的,优先直接把本地图片展示/发送给用户 + +## 模板文件 + +当你需要稳定的命令模板时,使用 `scripts/examples/` 下的文件: + +- `kuaishou_commands.ps1` +- `kuaishou_commands.sh` +- `kuaishou_cli_template.py` + +## 参考文档 + +- 运行前提:`references/runtime-requirements.md` +- CLI 契约:`references/cli-contract.md` +- 故障排查:`references/troubleshooting.md` diff --git a/skills/kuaishou-upload/references/cli-contract.md b/skills/kuaishou-upload/references/cli-contract.md new file mode 100644 index 0000000..7c3da52 --- /dev/null +++ b/skills/kuaishou-upload/references/cli-contract.md @@ -0,0 +1,95 @@ +# 快手 CLI 契约 + +这个 skill 默认假设当前环境已经安装并可调用 `sau` 命令。 + +## 命令列表 + +### 登录 + +```bash +sau kuaishou login --account +``` + +- 必填参数: + - `--account` +- 作用: + - 启动快手登录流程,为指定账号生成或刷新 cookie 文件 + - 如果登录过程中生成本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,而不是只回传路径 + +### 校验 cookie + +```bash +sau kuaishou check --account +``` + +- 必填参数: + - `--account` +- 预期输出: + - `valid`:cookie 可用 + - `invalid`:cookie 缺失或已失效 + +### 上传视频 + +```bash +sau kuaishou upload-video \ + --account \ + --file \ + --title "" \ + [--tags tag1,tag2] \ + [--schedule "YYYY-MM-DD HH:MM"] \ + [--thumbnail <image-path>] \ + [--debug] \ + [--headless | --headed] +``` + +- 必填参数: + - `--account` + - `--file` + - `--title` +- 可选参数: + - `--tags` + - `--schedule` + - `--thumbnail` + - `--debug` + - `--headless` + - `--headed` + +### 上传图文 + +```bash +sau kuaishou upload-note \ + --account <account> \ + --images <image-1> [image-2 ...] \ + --note "<content>" \ + [--tags tag1,tag2] \ + [--schedule "YYYY-MM-DD HH:MM"] \ + [--debug] \ + [--headless | --headed] +``` + +- 必填参数: + - `--account` + - `--images` + - `--note` +- 可选参数: + - `--tags` + - `--schedule` + - `--debug` + - `--headless` + - `--headed` + +## 发布策略 + +- 如果不传 `--schedule`,CLI 使用立即发布 +- 如果传了 `--schedule`,CLI 自动切换为定时发布 +- 时间格式为: + +```text +YYYY-MM-DD HH:MM +``` + +## 额外说明 + +- `upload-video` 每次命令只支持一个视频文件 +- `upload-note` 每次命令支持多张图片 +- `upload-note` 当前要求传入真实的多张图片文件,而不是同一路径重复多次 diff --git a/skills/kuaishou-upload/references/runtime-requirements.md b/skills/kuaishou-upload/references/runtime-requirements.md new file mode 100644 index 0000000..cfaa4ab --- /dev/null +++ b/skills/kuaishou-upload/references/runtime-requirements.md @@ -0,0 +1,67 @@ +# 运行前提 + +这个 skill 默认假设当前环境已经具备: + +- 已安装 `social-auto-upload` +- 可以调用 `sau` 命令,或至少有等效调用方式 +- 已为 `patchright` 安装 Chromium + +## 推荐安装方式 + +在项目根目录执行: + +```bash +uv pip install -e . +``` + +## 安装 patchright 浏览器 + +Windows PowerShell: + +```powershell +$env:PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright"; patchright install chromium +``` + +Linux / macOS(bash / zsh): + +```bash +PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright install chromium +``` + +## 常见调用方式 + +### 如果 `sau` 已经在 PATH 中 + +```bash +sau kuaishou --help +``` + +### 如果虚拟环境存在,但还没有激活 + +PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +sau kuaishou --help +``` + +### 如果你想直接调用可执行文件 + +PowerShell: + +```powershell +.\.venv\Scripts\sau.exe kuaishou --help +``` + +### 如果你更倾向于使用 uv + +```bash +uv run sau kuaishou --help +``` + +## 无头和有头模式 + +- 使用 `--headless` 表示无头模式 +- 使用 `--headed` 表示有头模式 +- 快手登录如果遇到二维码展示问题,优先切到 `--headed` +- 如果登录过程中已经生成了本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径 diff --git a/skills/kuaishou-upload/references/troubleshooting.md b/skills/kuaishou-upload/references/troubleshooting.md new file mode 100644 index 0000000..3f5360d --- /dev/null +++ b/skills/kuaishou-upload/references/troubleshooting.md @@ -0,0 +1,82 @@ +# 故障排查 + +## 找不到 `sau` 命令 + +可以尝试以下方式: + +```powershell +.\.venv\Scripts\Activate.ps1 +sau kuaishou --help +``` + +```powershell +.\.venv\Scripts\sau.exe kuaishou --help +``` + +```bash +uv run sau kuaishou --help +``` + +如果当前环境还没有安装项目: + +```bash +uv pip install -e . +``` + +## cookie 无效或已过期 + +先检查 cookie 状态: + +```bash +sau kuaishou check --account <account> +``` + +如果无效,就重新登录: + +```bash +sau kuaishou login --account <account> +``` + +## 登录二维码问题 + +如果用户反馈二维码在终端里不好扫: + +- 优先让用户切到 `--headed` +- 如果 CLI / uploader 已经生成临时二维码图片,agent 不要只回图片路径 +- agent 应优先直接把本地二维码图片展示/发送给用户扫码 +- 图片路径只作为补充信息 + +## 上传参数缺失 + +### 视频上传 + +最少需要: + +- `--account` +- `--file` +- `--title` + +### 图文上传 + +最少需要: + +- `--account` +- `--images` +- `--note` + +## 图文上传只有一张生效 + +如果用户一次传了多张图片,但页面只识别到一张,先确认: + +- `--images` 里传的是不是真正不同的文件 +- 不是同一路径重复多次 + +## 定时发布 + +时间格式使用: + +```text +YYYY-MM-DD HH:MM +``` + +如果不需要定时发布,去掉 `--schedule` 即可改为立即发布。 diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py b/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py new file mode 100644 index 0000000..b850e37 --- /dev/null +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import shlex +import subprocess + + +def run_command(command: list[str]) -> None: + print("Running:", " ".join(shlex.quote(part) for part in command)) + subprocess.run(command, check=True) + + +def main() -> None: + account = "creator" + + commands = [ + ["sau", "kuaishou", "login", "--account", account, "--headed"], + ["sau", "kuaishou", "check", "--account", account], + [ + "sau", + "kuaishou", + "upload-video", + "--account", + account, + "--file", + "videos/demo.mp4", + "--title", + "Kuaishou video from Python", + "--tags", + "cli,video", + "--thumbnail", + "videos/demo.png", + "--headless", + ], + [ + "sau", + "kuaishou", + "upload-note", + "--account", + account, + "--images", + "videos/1.png", + "videos/2.png", + "videos/3.png", + "--note", + "Kuaishou note from Python", + "--tags", + "cli,note", + "--headless", + ], + ] + + for command in commands: + run_command(command) + + +if __name__ == "__main__": + main() diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 new file mode 100644 index 0000000..32f7557 --- /dev/null +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 @@ -0,0 +1,24 @@ +# PowerShell examples for the installed sau CLI. + +$account = "creator" +$video = "videos/demo.mp4" +$thumbnail = "videos/demo.png" +$noteImages = @("videos/1.png", "videos/2.png", "videos/3.png") + +sau kuaishou login --account $account --headed +sau kuaishou check --account $account + +sau kuaishou upload-video ` + --account $account ` + --file $video ` + --title "Kuaishou video from PowerShell" ` + --tags "cli,video" ` + --thumbnail $thumbnail ` + --headless + +sau kuaishou upload-note ` + --account $account ` + --images $noteImages ` + --note "Kuaishou note from PowerShell" ` + --tags "cli,note" ` + --headless diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh new file mode 100644 index 0000000..c33faf3 --- /dev/null +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +account="creator" +video="videos/demo.mp4" +thumbnail="videos/demo.png" + +sau kuaishou login --account "$account" --headed +sau kuaishou check --account "$account" + +sau kuaishou upload-video \ + --account "$account" \ + --file "$video" \ + --title "Kuaishou video from bash" \ + --tags "cli,video" \ + --thumbnail "$thumbnail" \ + --headless + +sau kuaishou upload-note \ + --account "$account" \ + --images "videos/1.png" "videos/2.png" "videos/3.png" \ + --note "Kuaishou note from bash" \ + --tags "cli,note" \ + --headless diff --git a/uploader/ks_uploader/main.py b/uploader/ks_uploader/main.py index 7bf6aed..1603182 100644 --- a/uploader/ks_uploader/main.py +++ b/uploader/ks_uploader/main.py @@ -1,84 +1,350 @@ # -*- coding: utf-8 -*- -from datetime import datetime +from __future__ import annotations -from playwright.async_api import Playwright, async_playwright -import os import asyncio +import os +from datetime import datetime +from pathlib import Path -from conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS +from patchright.async_api import Page +from patchright.async_api import Playwright +from patchright.async_api import async_playwright + +from conf import DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH +from uploader.base_video import BaseVideoUploader from utils.base_social_media import set_init_script from utils.files_times import get_absolute_path +from utils.login_qrcode import build_login_qrcode_path +from utils.login_qrcode import decode_qrcode_from_path +from utils.login_qrcode import print_terminal_qrcode +from utils.login_qrcode import remove_qrcode_file +from utils.login_qrcode import save_data_url_image from utils.log import kuaishou_logger +KUAISHOU_UPLOAD_URL = "https://cp.kuaishou.com/article/publish/video" +KUAISHOU_MANAGE_URL = "https://cp.kuaishou.com/article/manage/video?status=2&from=publish" +KUAISHOU_LOGIN_URL = "https://passport.kuaishou.com/pc/account/login/?sid=kuaishou.web.cp.api&callback=https%3A%2F%2Fcp.kuaishou.com%2Frest%2Finfra%2Fsts%3FfollowUrl%3Dhttps%253A%252F%252Fcp.kuaishou.com%252Farticle%252Fpublish%252Fvideo%26setRootDomain%3Dtrue" +KUAISHOU_UPLOAD_URL_PATTERN = "**/article/publish/video**" +KUAISHOU_MANAGE_URL_PATTERN = "**/article/manage/video?status=2&from=publish**" +KUAISHOU_COOKIE_INVALID_SELECTOR = "div.names div.container div.name:text('机构服务')" +KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE = "immediate" +KUAISHOU_PUBLISH_STRATEGY_SCHEDULED = "scheduled" + + +def _msg(emoji: str, text: str) -> str: + return f"{emoji} {text}" + + +async def _is_ks_cookie_invalid(page: Page, timeout: int = 5000) -> bool: + try: + await page.wait_for_selector(KUAISHOU_COOKIE_INVALID_SELECTOR, timeout=timeout) + return True + except Exception: + return False + + +async def _extract_ks_qrcode_src(page: Page) -> str: + login_form = page.locator("main#login-form").first + await login_form.wait_for(state="visible", timeout=30000) + + qrcode_img = login_form.locator('div.qr-login img[alt="qrcode"]').first + try: + if not await qrcode_img.count() or not await qrcode_img.is_visible(): + platform_switch = login_form.locator("div.platform-switch").first + await platform_switch.wait_for(state="visible", timeout=10000) + await platform_switch.click() + await asyncio.sleep(1) + except Exception: + platform_switch = login_form.locator("div.platform-switch").first + await platform_switch.wait_for(state="visible", timeout=10000) + await platform_switch.click() + await asyncio.sleep(1) + + await qrcode_img.wait_for(state="visible", timeout=15000) + + qrcode_src = await qrcode_img.get_attribute("src") + if not qrcode_src: + raise RuntimeError("未获取到快手登录二维码地址") + + return qrcode_src + + +async def _save_ks_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None) -> dict: + qrcode_src = await _extract_ks_qrcode_src(page) + qrcode_path = save_data_url_image(qrcode_src, build_login_qrcode_path(account_file, suffix="ks_login_qrcode")) + + if previous_qrcode_path and previous_qrcode_path != qrcode_path: + if remove_qrcode_file(previous_qrcode_path): + kuaishou_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}")) + + kuaishou_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}")) + qrcode_content = decode_qrcode_from_path(qrcode_path) + if qrcode_content: + print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP", compact=False, border=2) + else: + kuaishou_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码")) + + return { + "image_path": str(qrcode_path), + "image_data_url": qrcode_src, + } + + +async def _is_ks_qrcode_expired(page: Page) -> bool: + expired_box = page.locator("div.qrcode-status.qrcode-status-timeout").first + try: + if not await expired_box.count(): + return False + return await expired_box.is_visible() + except Exception: + return False + + +async def _is_ks_login_page_gone(page: Page) -> bool: + try: + login_form = page.locator("main#login-form").first + if not await login_form.count(): + return True + return not await login_form.is_visible() + except Exception: + return True + async def cookie_auth(account_file): async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS) - context = await browser.new_context(storage_state=account_file) - context = await set_init_script(context) - # 创建一个新的页面 - page = await context.new_page() - # 访问指定的 URL - await page.goto("https://cp.kuaishou.com/article/publish/video") + if LOCAL_CHROME_PATH: + browser = await playwright.chromium.launch(headless=True, executable_path=LOCAL_CHROME_PATH) + else: + browser = await playwright.chromium.launch(headless=True, channel="chrome") try: - await page.wait_for_selector("div.names div.container div.name:text('机构服务')", timeout=5000) # 等待5秒 + context = await browser.new_context(storage_state=account_file) + context = await set_init_script(context) + page = await context.new_page() + await page.goto(KUAISHOU_UPLOAD_URL) + if await _is_ks_cookie_invalid(page): + kuaishou_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下")) + return False - kuaishou_logger.info("[+] 等待5秒 cookie 失效") - return False - except: - kuaishou_logger.success("[+] cookie 有效") + kuaishou_logger.success(_msg("🥳", "cookie 有效")) return True + except Exception as exc: + kuaishou_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}")) + return False + finally: + await browser.close() -async def ks_setup(account_file, handle=False): +async def ks_setup(account_file, handle=False, headless: bool = LOCAL_CHROME_HEADLESS): account_file = get_absolute_path(account_file, "ks_uploader") if not os.path.exists(account_file) or not await cookie_auth(account_file): if not handle: return False - kuaishou_logger.info('[+] cookie文件不存在或已失效,即将自动打开浏览器,请扫码登录,登陆后会自动生成cookie文件') - await get_ks_cookie(account_file) + kuaishou_logger.info(_msg("🥹", "cookie 失效了,准备重新登录快手创作者平台")) + return await get_ks_cookie(account_file, headless=headless) + return True -async def get_ks_cookie(account_file): +async def get_ks_cookie(account_file, headless: bool = LOCAL_CHROME_HEADLESS, poll_interval: int = 3, max_checks: int = 100): + if headless: + kuaishou_logger.warning(_msg("😵", "快手登录暂未接入抖音那套二维码透传,先切到有头模式方便你扫码")) + headless = False + async with async_playwright() as playwright: - options = { - 'args': [ - '--lang en-GB' - ], - 'headless': LOCAL_CHROME_HEADLESS, # Set headless option here - } - # Make sure to run headed. - browser = await playwright.chromium.launch(**options) - # Setup context however you like. - context = await browser.new_context() # Pass any options + if LOCAL_CHROME_PATH: + browser = await playwright.chromium.launch(headless=headless, executable_path=LOCAL_CHROME_PATH) + else: + browser = await playwright.chromium.launch(headless=headless, channel="chrome") + context = await browser.new_context() context = await set_init_script(context) - # Pause the page, and start recording manually. - page = await context.new_page() - await page.goto("https://cp.kuaishou.com") - await page.pause() - # 点击调试器的继续,保存cookie - await context.storage_state(path=account_file) + qrcode_path = None + login_success = False + try: + page = await context.new_page() + await page.goto(KUAISHOU_LOGIN_URL) + kuaishou_logger.info(_msg("🧍", "请在浏览器里扫码登录快手,小人正在耐心等待")) + + qrcode_info = await _save_ks_qrcode(page, account_file) + qrcode_path = Path(qrcode_info["image_path"]) + + for _ in range(max_checks): + if page.url.startswith(KUAISHOU_UPLOAD_URL) or await _is_ks_login_page_gone(page): + await context.storage_state(path=account_file) + if await cookie_auth(account_file): + kuaishou_logger.success(_msg("🥳", "快手扫码登录成功,小人开心收工")) + login_success = True + else: + kuaishou_logger.error(_msg("😢", "快手扫码完成了,但 cookie 校验失败")) + return login_success + + if qrcode_info and await _is_ks_qrcode_expired(page): + kuaishou_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新")) + refresh_button = page.locator("p.qrcode-refresh").first + if await refresh_button.count(): + await refresh_button.click() + await asyncio.sleep(1) + qrcode_info = await _save_ks_qrcode(page, account_file, qrcode_path) + qrcode_path = Path(qrcode_info["image_path"]) + + await asyncio.sleep(poll_interval) + except Exception as exc: + kuaishou_logger.error(_msg("😢", f"登录失败: {exc}")) + finally: + if remove_qrcode_file(qrcode_path): + kuaishou_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}")) + await context.close() + await browser.close() + + kuaishou_logger.error(_msg("😢", "等待快手扫码登录超时")) + return False -class KSVideo(object): - def __init__(self, title, file_path, tags, publish_date: datetime, account_file): - self.title = title # 视频标题 - self.file_path = file_path - self.tags = tags +class KSBaseUploader(BaseVideoUploader): + def __init__( + self, + publish_date: datetime | int, + account_file, + publish_strategy: str | None = None, + debug: bool = DEBUG_MODE, + headless: bool = LOCAL_CHROME_HEADLESS, + ): self.publish_date = publish_date - self.account_file = account_file - self.date_format = '%Y-%m-%d %H:%M' + self.account_file = str(account_file) + self.publish_strategy = publish_strategy + self.debug = debug + self.headless = headless self.local_executable_path = LOCAL_CHROME_PATH - self.headless = LOCAL_CHROME_HEADLESS + self.date_format = "%Y-%m-%d %H:%M" - async def handle_upload_error(self, page): - kuaishou_logger.error("视频出错了,重新上传中") + async def validate_base_args(self): + if not os.path.exists(self.account_file): + raise RuntimeError(f"cookie文件不存在,请先完成快手登录: {self.account_file}") + if not await cookie_auth(self.account_file): + raise RuntimeError(f"cookie文件已失效,请先完成快手登录: {self.account_file}") + + if self.publish_strategy is None: + self.publish_strategy = ( + KUAISHOU_PUBLISH_STRATEGY_SCHEDULED + if self.publish_date != 0 + else KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE + ) + + if self.publish_strategy not in { + KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE, + KUAISHOU_PUBLISH_STRATEGY_SCHEDULED, + }: + raise ValueError(f"不支持的发布策略: {self.publish_strategy}") + + if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED: + self.publish_date = self.validate_publish_date(self.publish_date) + else: + self.publish_date = 0 + + async def set_schedule_time(self, page: Page, publish_date: datetime): + kuaishou_logger.info(_msg("🕒", "小人准备设置定时发布时间")) + publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M:%S") + await page.locator("label:text('发布时间')").locator("xpath=following-sibling::div").locator(".ant-radio-input").nth(1).click() + await asyncio.sleep(1) + await page.locator('div.ant-picker-input input[placeholder="选择日期时间"]').click() + await asyncio.sleep(1) + await page.keyboard.press("Control+KeyA") + await page.keyboard.type(publish_date_hour) + await page.keyboard.press("Enter") + await asyncio.sleep(1) + + async def close_guide_overlay(self, page: Page) -> bool: + joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]') + + # 判断是否显示 + if await joyride_tooltip.count() > 0 and await joyride_tooltip.first.is_visible(): + print("检测到 Joyride 引导遮罩,正在关闭...") + + # 点击关闭按钮(X),使用多个可靠特征 + close_button = page.locator('div[role="alertdialog"]').locator( + '[aria-label="Skip"], [data-action="skip"], button[title="Skip"]' + ) + + await close_button.click(force=True) + + # 等待遮罩消失 + await joyride_tooltip.wait_for(state="hidden", timeout=5000) + + print("✅ 已关闭 Joyride 遮罩") + else: + print("未检测到 Joyride 遮罩,继续执行") + + +class KSVideo(KSBaseUploader): + def __init__( + self, + title, + file_path, + tags, + publish_date: datetime | int, + account_file, + publish_strategy: str | None = None, + debug: bool = DEBUG_MODE, + headless: bool = LOCAL_CHROME_HEADLESS, + thumbnail_path=None, + ): + super().__init__( + publish_date=publish_date, + account_file=account_file, + publish_strategy=publish_strategy, + debug=debug, + headless=headless, + ) + self.title = title + self.file_path = file_path + self.tags = tags or [] + self.thumbnail_path = thumbnail_path + + async def validate_upload_args(self): + await self.validate_base_args() + if not self.title or not str(self.title).strip(): + raise ValueError("快手视频上传时,title 是必须的") + self.file_path = str(self.validate_video_file(self.file_path)) + if self.thumbnail_path: + self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) + + async def handle_upload_error(self, page: Page): + kuaishou_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传")) await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path) + async def set_thumbnail(self, page: Page): + if not self.thumbnail_path: + return + + kuaishou_logger.info(_msg("🖼️", "小人准备设置封面")) + + cover_label = page.locator("span").filter(has_text="封面设置") + await cover_label.wait_for(state="visible", timeout=30000) + await cover_label.locator("xpath=../following-sibling::div[1]").locator('div').nth(0).click() + + modal = page.locator('div[role="document"].ant-modal') + await modal.wait_for(state="visible", timeout=30000) + + upload_cover_tab = modal.get_by_text("上传封面", exact=True) + await upload_cover_tab.wait_for(state="visible", timeout=10000) + await upload_cover_tab.click() + + file_input = modal.locator('input[type="file"]') + await file_input.wait_for(state="attached", timeout=30000) + await file_input.set_input_files(self.thumbnail_path) + await asyncio.sleep(1) + + confirm_button = modal.get_by_role("button", name="确认", exact=True) + await confirm_button.wait_for(state="visible", timeout=10000) + await confirm_button.click() + + await modal.wait_for(state="hidden", timeout=30000) + kuaishou_logger.success(_msg("🥳", "封面已经设置完成")) + async def upload(self, playwright: Playwright) -> None: - # 使用 Chromium 浏览器启动一个浏览器实例 - print(self.local_executable_path) + kuaishou_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间")) + await self.validate_upload_args() + kuaishou_logger.info(_msg("🥳", "上传前检查通过")) + if self.local_executable_path: browser = await playwright.chromium.launch( headless=self.headless, @@ -86,83 +352,218 @@ class KSVideo(object): ) else: browser = await playwright.chromium.launch( - headless=self.headless - ) # 创建一个浏览器上下文,使用指定的 cookie 文件 - context = await browser.new_context(storage_state=f"{self.account_file}") + headless=self.headless, + channel="chrome", + ) + context = await browser.new_context(storage_state=self.account_file) context = await set_init_script(context) - # 创建一个新的页面 - page = await context.new_page() - # 访问指定的 URL - await page.goto("https://cp.kuaishou.com/article/publish/video") - kuaishou_logger.info('正在上传-------{}.mp4'.format(self.title)) - # 等待页面跳转到指定的 URL,没进入,则自动等待到超时 - kuaishou_logger.info('正在打开主页...') - await page.wait_for_url("https://cp.kuaishou.com/article/publish/video") - # 点击 "上传视频" 按钮 - upload_button = page.locator("button[class^='_upload-btn']") - await upload_button.wait_for(state='visible') # 确保按钮可见 + + upload_success = False + try: + page = await context.new_page() + await page.goto(KUAISHOU_UPLOAD_URL) + kuaishou_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4")) + kuaishou_logger.info(_msg("🧭", "小人正在赶往快手上传主页")) + await page.wait_for_url(KUAISHOU_UPLOAD_URL_PATTERN) + + upload_button = page.locator("button[class^='_upload-btn']") + await upload_button.wait_for(state="visible", timeout=10000) + + async with page.expect_file_chooser() as fc_info: + await upload_button.click() + file_chooser = await fc_info.value + await file_chooser.set_files(self.file_path) + + await asyncio.sleep(2) + + know_button = page.locator('button[type="button"] span:text("我知道了")').first + try: + if await know_button.count() and await know_button.is_visible(): + await know_button.click() + except Exception: + pass + + await self.close_guide_overlay(page) + + kuaishou_logger.info(_msg("✍️", "小人开始填描述和话题")) + await page.get_by_text("描述").locator("xpath=following-sibling::div").click() + await page.keyboard.press("Backspace") + await page.keyboard.press("Control+KeyA") + await page.keyboard.press("Delete") + await page.keyboard.type(self.title) + await page.keyboard.press("Enter") + + for index, tag in enumerate(self.tags[:3], start=1): + kuaishou_logger.info(_msg("🏷️", f"小人正在添加第 {index} 个话题: #{tag}")) + await page.keyboard.type(f"#{tag} ") + await asyncio.sleep(2) + + max_retries = 60 + retry_count = 0 + while retry_count < max_retries: + try: + number = await page.locator("text=上传中").count() + if number == 0: + kuaishou_logger.success(_msg("🥳", "视频已经传完啦")) + break + + if retry_count % 5 == 0: + kuaishou_logger.info(_msg("🏃", "小人正在努力上传视频")) + + if await page.locator("text=上传失败").count(): + await self.handle_upload_error(page) + + await asyncio.sleep(2) + except Exception as exc: + kuaishou_logger.warning(_msg("😵", f"检查上传状态时出错,小人继续重试: {exc}")) + await asyncio.sleep(2) + retry_count += 1 + + if retry_count == max_retries: + kuaishou_logger.warning(_msg("😵", "超过最大重试次数,视频上传可能未完成")) + + await self.set_thumbnail(page) + + if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: + await self.set_schedule_time(page, self.publish_date) + + while True: + try: + publish_button = page.get_by_text("发布", exact=True) + if await publish_button.count() > 0: + await publish_button.click() + + await asyncio.sleep(1) + confirm_button = page.get_by_text("确认发布") + if await confirm_button.count() > 0: + await confirm_button.click() + + await page.wait_for_url(KUAISHOU_MANAGE_URL_PATTERN, timeout=5000) + kuaishou_logger.success(_msg("🥳", "视频发布成功,小人开心收工")) + break + except Exception as exc: + kuaishou_logger.info(_msg("🏃", f"小人正在冲刺发布视频: {exc}")) + if self.debug: + await page.screenshot(full_page=True) + await asyncio.sleep(1) + + upload_success = True + finally: + if upload_success: + await context.storage_state(path=self.account_file) + kuaishou_logger.success(_msg("🥳", "cookie 更新完毕")) + await asyncio.sleep(2) + await context.close() + await browser.close() + + async def main(self): + async with async_playwright() as playwright: + await self.upload(playwright) + + +class KSNote(KSBaseUploader): + def __init__( + self, + image_paths, + note, + tags, + publish_date: datetime | int, + account_file, + publish_strategy: str | None = None, + debug: bool = DEBUG_MODE, + headless: bool = LOCAL_CHROME_HEADLESS, + ): + super().__init__( + publish_date=publish_date, + account_file=account_file, + publish_strategy=publish_strategy, + debug=debug, + headless=headless, + ) + self.image_paths = image_paths + self.note = note + self.tags = tags or [] + + async def validate_upload_args(self): + await self.validate_base_args() + if not self.note or not str(self.note).strip(): + raise ValueError("快手图文上传时,note 是必须的") + if not self.image_paths: + raise ValueError("快手图文上传时,图片是必须的") + + if isinstance(self.image_paths, (str, Path)): + self.image_paths = [self.image_paths] + + normalized_image_paths = [] + for image_path in self.image_paths: + normalized_image_paths.append(str(self.validate_image_file(image_path))) + self.image_paths = normalized_image_paths + + async def upload_note_content(self, page: Page) -> None: + kuaishou_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片")) + kuaishou_logger.info(_msg("🔀", "小人正在切换到图文发布")) + await page.locator('div[role="tablist"] div[role="tab"]:has-text("图文")').click() + await page.wait_for_timeout(1000) + + kuaishou_logger.info(_msg("📤", "小人正在上传图片")) + upload_button = page.locator("button[class^='_upload-btn']").filter(has_text="上传图片") + await upload_button.wait_for(state="visible", timeout=10000) async with page.expect_file_chooser() as fc_info: await upload_button.click() file_chooser = await fc_info.value - await file_chooser.set_files(self.file_path) + await file_chooser.set_files(self.image_paths) - await asyncio.sleep(2) + know_button = page.locator('button[type="button"] span:text("我知道了")').first + try: + if await know_button.count() and await know_button.is_visible(): + await know_button.click() + except Exception: + pass - # if not await page.get_by_text("封面编辑").count(): - # raise Exception("似乎没有跳转到到编辑页面") + await self.close_guide_overlay(page) - await asyncio.sleep(1) - - # 等待按钮可交互 - new_feature_button = page.locator('button[type="button"] span:text("我知道了")') - if await new_feature_button.count() > 0: - await new_feature_button.click() - - kuaishou_logger.info("正在填充标题和话题...") + kuaishou_logger.info(_msg("✍️", "小人开始填写图文内容和话题")) await page.get_by_text("描述").locator("xpath=following-sibling::div").click() - kuaishou_logger.info("clear existing title") await page.keyboard.press("Backspace") await page.keyboard.press("Control+KeyA") await page.keyboard.press("Delete") - kuaishou_logger.info("filling new title") - await page.keyboard.type(self.title) + await page.keyboard.type(self.note) await page.keyboard.press("Enter") - # 快手只能添加3个话题 for index, tag in enumerate(self.tags[:3], start=1): - kuaishou_logger.info("正在添加第%s个话题" % index) + kuaishou_logger.info(_msg("🏷️", f"小人正在添加第 {index} 个话题: #{tag}")) await page.keyboard.type(f"#{tag} ") await asyncio.sleep(2) - max_retries = 60 # 设置最大重试次数,最大等待时间为 2 分钟 + max_retries = 60 retry_count = 0 - while retry_count < max_retries: try: - # 获取包含 '上传中' 文本的元素数量 number = await page.locator("text=上传中").count() - if number == 0: - kuaishou_logger.success("视频上传完毕") + kuaishou_logger.success(_msg("🥳", "图文素材已经传完啦")) break - else: - if retry_count % 5 == 0: - kuaishou_logger.info("正在上传视频中...") - await asyncio.sleep(2) - except Exception as e: - kuaishou_logger.error(f"检查上传状态时发生错误: {e}") - await asyncio.sleep(2) # 等待 2 秒后重试 + + if retry_count % 5 == 0: + kuaishou_logger.info(_msg("🏃", "小人正在努力上传图文素材")) + + if await page.locator("text=上传失败").count(): + kuaishou_logger.warning(_msg("😵", "图文素材上传摔了一跤,小人马上重新上传")) + await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.image_paths) + + await asyncio.sleep(2) + except Exception as exc: + kuaishou_logger.warning(_msg("😵", f"检查图文上传状态时出错,小人继续重试: {exc}")) + await asyncio.sleep(2) retry_count += 1 if retry_count == max_retries: - kuaishou_logger.warning("超过最大重试次数,视频上传可能未完成。") + kuaishou_logger.warning(_msg("😵", "超过最大重试次数,图文上传可能未完成")) - # 定时任务 - if self.publish_date != 0: + if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: await self.set_schedule_time(page, self.publish_date) - # 判断视频是否发布成功 while True: try: publish_button = page.get_by_text("发布", exact=True) @@ -174,40 +575,50 @@ class KSVideo(object): if await confirm_button.count() > 0: await confirm_button.click() - # 等待页面跳转,确认发布成功 - await page.wait_for_url( - "https://cp.kuaishou.com/article/manage/video?status=2&from=publish", - timeout=5000, - ) - kuaishou_logger.success("视频发布成功") + await page.wait_for_url(KUAISHOU_MANAGE_URL_PATTERN, timeout=5000) + kuaishou_logger.success(_msg("🥳", "图文发布成功,小人开心收工")) break - except Exception as e: - kuaishou_logger.info(f"视频正在发布中... 错误: {e}") - await page.screenshot(full_page=True) + except Exception as exc: + kuaishou_logger.info(_msg("🏃", f"小人正在冲刺发布图文: {exc}")) + if self.debug: + await page.screenshot(full_page=True) await asyncio.sleep(1) - await context.storage_state(path=self.account_file) # 保存cookie - kuaishou_logger.info('cookie更新完毕!') - await asyncio.sleep(2) # 这里延迟是为了方便眼睛直观的观看 - # 关闭浏览器上下文和浏览器实例 - await context.close() - await browser.close() + async def upload(self, playwright: Playwright) -> None: + kuaishou_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间")) + await self.validate_upload_args() + kuaishou_logger.info(_msg("🥳", "图文上传前检查通过")) + + if self.local_executable_path: + browser = await playwright.chromium.launch( + headless=self.headless, + executable_path=self.local_executable_path, + ) + else: + browser = await playwright.chromium.launch( + headless=self.headless, + channel="chrome", + ) + context = await browser.new_context(storage_state=self.account_file) + context = await set_init_script(context) + + upload_success = False + try: + page = await context.new_page() + await page.goto(KUAISHOU_UPLOAD_URL) + kuaishou_logger.info(_msg("🧭", "小人正在赶往快手图文发布页")) + await page.wait_for_url(KUAISHOU_UPLOAD_URL_PATTERN) + + await self.upload_note_content(page) + upload_success = True + finally: + if upload_success: + await context.storage_state(path=self.account_file) + kuaishou_logger.success(_msg("🥳", "cookie 更新完毕")) + await asyncio.sleep(2) + await context.close() + await browser.close() async def main(self): async with async_playwright() as playwright: await self.upload(playwright) - - async def set_schedule_time(self, page, publish_date): - kuaishou_logger.info("click schedule") - publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M:%S") - await page.locator("label:text('发布时间')").locator('xpath=following-sibling::div').locator( - '.ant-radio-input').nth(1).click() - await asyncio.sleep(1) - - await page.locator('div.ant-picker-input input[placeholder="选择日期时间"]').click() - await asyncio.sleep(1) - - await page.keyboard.press("Control+KeyA") - await page.keyboard.type(str(publish_date_hour)) - await page.keyboard.press("Enter") - await asyncio.sleep(1) diff --git a/utils/log.py b/utils/log.py index 6a4b98f..3271da9 100644 --- a/utils/log.py +++ b/utils/log.py @@ -1,10 +1,16 @@ +import sys from pathlib import Path -from sys import stdout from loguru import logger from conf import BASE_DIR +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + + def log_formatter(record: dict) -> str: """ Formatter for log records. @@ -41,7 +47,7 @@ def create_logger(log_name: str, file_path: str): # Remove all existing handlers logger.remove() # Add a standard console handler -logger.add(stdout, colorize=True, format=log_formatter) +logger.add(sys.stdout, colorize=True, format=log_formatter) douyin_logger = create_logger('douyin', 'logs/douyin.log') tencent_logger = create_logger('tencent', 'logs/tencent.log') diff --git a/utils/login_qrcode.py b/utils/login_qrcode.py index 271386d..dda3ed3 100644 --- a/utils/login_qrcode.py +++ b/utils/login_qrcode.py @@ -57,14 +57,20 @@ def _print_ascii_qrcode(qrcode) -> None: print(empty_line) -def print_terminal_qrcode(qrcode_content: str, qrcode_path: Path, app_name: str) -> None: +def print_terminal_qrcode( + qrcode_content: str, + qrcode_path: Path, + app_name: str, + compact: bool = True, + border: int = 0, +) -> None: print() print(f"请使用{app_name}扫描下方二维码登录:") qrcode = segno.make(qrcode_content, error="L", boost_error=False) try: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") - qrcode.terminal(compact=True, border=0) + qrcode.terminal(compact=compact, border=border) except (UnicodeEncodeError, OSError): print("当前终端不支持 Unicode 二维码字符,已切换为 ASCII 打印:") _print_ascii_qrcode(qrcode) diff --git a/videos/demo1.png b/videos/demo1.png new file mode 100644 index 0000000..9a749c5 Binary files /dev/null and b/videos/demo1.png differ diff --git a/videos/demo2.png b/videos/demo2.png new file mode 100644 index 0000000..9a749c5 Binary files /dev/null and b/videos/demo2.png differ