1. 重构快手
- 图文增加 - 封面增加 2. 快手skill,cli支持
This commit is contained in:
@@ -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`
|
||||
|
||||
+35
-5
@@ -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
@@ -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
@@ -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 应直接把图片展示/发送给用户扫码,不要只回传图片路径
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
+179
-15
@@ -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,22 +288,46 @@ 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_account(args.account, headless=args.headless)
|
||||
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 == "check":
|
||||
is_valid = await check_account(args.account)
|
||||
is_valid = await check_douyin_account(args.account)
|
||||
print("valid" if is_valid else "invalid")
|
||||
return 0 if is_valid else 1
|
||||
|
||||
@@ -254,6 +368,56 @@ async def dispatch(args: argparse.Namespace) -> int:
|
||||
|
||||
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:
|
||||
parser = build_parser()
|
||||
|
||||
@@ -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 应优先直接把本地二维码图片展示/发送给用户扫码
|
||||
|
||||
如果终端二维码显示不正常,优先使用保存下来的图片路径,而不是反复尝试随机的终端设置。
|
||||
|
||||
|
||||
@@ -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
|
||||
+515
-104
@@ -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)
|
||||
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:
|
||||
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")
|
||||
try:
|
||||
await page.wait_for_selector("div.names div.container div.name:text('机构服务')", timeout=5000) # 等待5秒
|
||||
|
||||
kuaishou_logger.info("[+] 等待5秒 cookie 失效")
|
||||
await page.goto(KUAISHOU_UPLOAD_URL)
|
||||
if await _is_ks_cookie_invalid(page):
|
||||
kuaishou_logger.info(_msg("🥹", "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.
|
||||
qrcode_path = None
|
||||
login_success = False
|
||||
try:
|
||||
page = await context.new_page()
|
||||
await page.goto("https://cp.kuaishou.com")
|
||||
await page.pause()
|
||||
# 点击调试器的继续,保存cookie
|
||||
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,21 +352,22 @@ 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)
|
||||
# 创建一个新的页面
|
||||
|
||||
upload_success = False
|
||||
try:
|
||||
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")
|
||||
# 点击 "上传视频" 按钮
|
||||
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') # 确保按钮可见
|
||||
await upload_button.wait_for(state="visible", timeout=10000)
|
||||
|
||||
async with page.expect_file_chooser() as fc_info:
|
||||
await upload_button.click()
|
||||
@@ -109,60 +376,57 @@ class KSVideo(object):
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# if not await page.get_by_text("封面编辑").count():
|
||||
# raise Exception("似乎没有跳转到到编辑页面")
|
||||
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 asyncio.sleep(1)
|
||||
await self.close_guide_overlay(page)
|
||||
|
||||
# 等待按钮可交互
|
||||
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.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("正在上传视频中...")
|
||||
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)
|
||||
except Exception as e:
|
||||
kuaishou_logger.error(f"检查上传状态时发生错误: {e}")
|
||||
await asyncio.sleep(2) # 等待 2 秒后重试
|
||||
retry_count += 1
|
||||
|
||||
if retry_count == max_retries:
|
||||
kuaishou_logger.warning("超过最大重试次数,视频上传可能未完成。")
|
||||
kuaishou_logger.warning(_msg("😵", "超过最大重试次数,视频上传可能未完成"))
|
||||
|
||||
# 定时任务
|
||||
if self.publish_date != 0:
|
||||
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)
|
||||
@@ -174,22 +438,21 @@ 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}")
|
||||
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) # 这里延迟是为了方便眼睛直观的观看
|
||||
# 关闭浏览器上下文和浏览器实例
|
||||
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()
|
||||
|
||||
@@ -197,17 +460,165 @@ class KSVideo(object):
|
||||
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)
|
||||
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.image_paths)
|
||||
|
||||
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.type(str(publish_date_hour))
|
||||
await page.keyboard.press("Delete")
|
||||
await page.keyboard.type(self.note)
|
||||
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():
|
||||
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(_msg("😵", "超过最大重试次数,图文上传可能未完成"))
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
+8
-2
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Reference in New Issue
Block a user