1. 重构快手

- 图文增加
- 封面增加
2. 快手skill,cli支持
This commit is contained in:
home-dev-pookz
2026-03-25 02:25:02 +08:00
parent 2934328888
commit b415613b38
25 changed files with 1343 additions and 227 deletions
+11 -4
View File
@@ -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
LINUX DO - The New Ideal Community
+35 -5
View File
@@ -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/`。
+22 -1
View File
@@ -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 主入口
+10 -1
View File
@@ -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 应直接把图片展示/发送给用户扫码,不要只回传图片路径
+8 -3
View File
@@ -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()
-1
View File
@@ -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,
+64
View File
@@ -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()
-26
View File
@@ -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)
+219 -55
View File
@@ -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:
+2
View File
@@ -47,6 +47,8 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成抖音登录、c
- 如果 `sau` 不可用,按 `references/runtime-requirements.md` 里的回退方式处理
- 当用户明确指定无头或有头模式时,显式传 `--headless` 或 `--headed`
- 只有用户明确要求定时发布时,才使用 `--schedule`
- 如果登录流程生成了本地二维码图片,不要只把图片路径告诉用户
- 二维码图片本身就是给用户扫码的,优先直接把本地图片展示/发送给用户
## 模板文件
@@ -14,6 +14,7 @@ sau douyin login --account <account>
- `--account`
- 作用:
- 启动抖音登录流程,为指定账号生成或刷新 cookie 文件
- 如果登录过程中生成本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,而不是只回传路径
### 校验 cookie
@@ -64,3 +64,4 @@ uv run sau douyin --help
- 使用 `--headless` 表示无头模式
- 使用 `--headed` 表示有头模式
- 如果用户明确要求无头登录,也要预期 CLI 会通过控制台输出或临时图片路径提供二维码相关提示
- 如果登录过程中已经生成了本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径
@@ -41,8 +41,9 @@ sau douyin login --account <account>
如果用户无法使用终端二维码输出:
- 查找 CLI 打印出来的临时二维码图片路径
- 让用户直接用抖音 APP 扫描该图片
- 查找 CLI 打印出来的临时二维码图片
- agent 不要只把图片路径回给用户
- agent 应优先直接把本地二维码图片展示/发送给用户扫码
如果终端二维码显示不正常,优先使用保存下来的图片路径,而不是反复尝试随机的终端设置。
+65
View File
@@ -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 <name>` | 生成或刷新指定账号的 cookie |
| cookie 校验 | `sau kuaishou check --account <name>` | 检查指定账号 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 <name>` 登录快手
- 使用 `sau kuaishou check --account <name>` 校验 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`
@@ -0,0 +1,95 @@
# 快手 CLI 契约
这个 skill 默认假设当前环境已经安装并可调用 `sau` 命令。
## 命令列表
### 登录
```bash
sau kuaishou login --account <account>
```
- 必填参数:
- `--account`
- 作用:
- 启动快手登录流程,为指定账号生成或刷新 cookie 文件
- 如果登录过程中生成本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,而不是只回传路径
### 校验 cookie
```bash
sau kuaishou check --account <account>
```
- 必填参数:
- `--account`
- 预期输出:
- `valid`:cookie 可用
- `invalid`:cookie 缺失或已失效
### 上传视频
```bash
sau kuaishou upload-video \
--account <account> \
--file <video-path> \
--title "<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` 当前要求传入真实的多张图片文件,而不是同一路径重复多次
@@ -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 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径
@@ -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` 即可改为立即发布。
@@ -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()
@@ -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
@@ -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
+536 -125
View File
@@ -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)
+8 -2
View File
@@ -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')
+8 -2
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB