commit f0ecfa6de09769cbf3058d85a3fd5e6f58c6c7ea Author: nxxy335 Date: Sun Sep 27 22:41:43 2026 +0800 chore: 初始化 123yunpan skill 仓库 (v1.3.0) - 完整 skill 本体:SKILL.md / README.md / manifest.json / config.json - scripts/123yunpan.py:零依赖 CLI(含空间双配额、QPS 限流、零依赖 XLSX) - references/ 参考资料 - 排除 .workbuddy/ 开发目录与 __pycache__ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e98e31a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# 开发目录(内存、回归脚本、缓存)不参与版本控制 +.workbuddy/ + +# Python 缓存 +__pycache__/ +*.pyc +*.pyo + +# 系统文件 +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..030950d --- /dev/null +++ b/README.md @@ -0,0 +1,368 @@ +# 123云盘 (123 Cloud Drive) CLI + +> 🚀 123云盘开放平台 API 命令行工具 v1.3.0 + +一站式管理 123 云盘:文件管理、上传下载、分享链接、离线下载、直链、图床、视频转码、专业/标准双空间配额报告。纯 Python 标准库实现,零依赖(**Excel 导出也不需要 openpyxl**)。 + +--- + +## 📋 功能一览 + +| 分类 | 功能 | +|------|------| +| 📁 文件管理 | 浏览、创建目录、重命名、移动、复制、回收站、下载 | +| 🔎 递归搜索 | 跨文件夹深度搜索文件名,支持自定义搜索深度 | +| ⬆️ 上传 | V2 分片上传(>100MB 自动分片)、单步上传、**秒传检测** | +| 🔗 分享 | 普通分享 / 付费分享 / **批量创建**,支持密码、有效期、流量控制 | +| 📊 分享统计 | 一键查看分享总数、预览/下载/转存次数、流量 Top N | +| 📄 分享导出 | 批量创建分享,超过 10 个链接自动生成**三列 Excel**(文件名称 / 链接 / 路径) | +| 🗑️ 分享管理 | 批量删除分享链接 / 断点续跑 | +| 📥 离线下载 | HTTP / FTP / 磁力链接离线下载 | +| 🌐 直链 | 启用直链、**短直链生成**、IP 白名单、缓存刷新、流量日志 | +| 🖼️ 图床 | 图片存储、移动、复制、离线迁移 | +| 🎬 视频转码 | 多分辨率转码、MP4/M3U8 下载 | +| 🔒 保险箱 | 解锁保险箱 | +| ⚙️ 配置管理 | 配置查看、**Token 有效性验证** | +| 🌲 目录树 | 文件夹树形结构展示 | +| ⬇️ 批量下载 | 多文件并行下载 | +| 📊 存储分析 | 按文件夹统计空间占用 | +| 📤 分享导出 | 分享链接批量导出 Excel | +| 🔄 配置迁移 | 配置导入/导出 | +| 💾 空间报告 | 专业空间 / 标准空间**双配额**明细、使用率预警、清理倒计时 | + +--- + +## 🧮 空间规则:专业空间 / 标准空间 + +官方 2026-09-24 公告后,账号空间拆分为**两套独立配额**: + +| 空间 | 存放内容 | 超额后果 | +|------|----------|----------| +| **专业空间** | 本人持有的文件、≥10GB 文件、直链文件、转码文件、图床文件 | 阻断开放平台的**上传 / 离线下载 / 转码** | +| **标准空间** | 转存的文件(<10GB);重复文件与回收站文件不计入 | 同样阻断上传 | + +```bash +python3 scripts/123yunpan.py space +``` + +- 使用率 ≥ 90% → 红色预警 +- 专业空间剩余天数 > 0 → 打印清理倒计时(超出后配额不可恢复) +- `config check` 与 `upload` 执行前自动预检,专业空间已满会直接给出阻断提示 + +--- + +## 🚀 快速开始 + +### 1. 获取凭证 + +1. 访问 [123pan.com/member](https://www.123pan.com/member) 购买**开发者权益包** +2. 通过站内信获取 `client_id` 和 `client_secret` + +### 2. 配置 + +```bash +# 一键配置(推荐) +python3 scripts/123yunpan.py setup --client-id YOUR_ID --client-secret YOUR_SECRET + +# 验证配置是否有效 +python3 scripts/123yunpan.py config check +``` + +输出示例: +``` +🔍 正在验证配置... + ✅ Client ID: 已配置 + ✅ Client Secret: 已配置 + ✅ Access Token: 已配置 + +✅ Token 有效! +{ + "用户ID": 1812749510, + "昵称": "暖心向阳335", + "VIP": "是", + "总容量": "21.56 TB", + "已用": "859.33 GB" +} +``` + +--- + +## 📖 完整命令参考 + +### 文件管理 + +```bash +# 浏览文件夹 +python3 scripts/123yunpan.py file list --parent-id 0 --limit 100 + +# 只看回收站中的文件 +python3 scripts/123yunpan.py file list --parent-id 0 --trashed + +# 同时显示回收站文件(带 🗑️ 标记) +python3 scripts/123yunpan.py file list --parent-id 0 --show-trashed + +# 跨文件夹递归搜索(最大深度 5 层) +python3 scripts/123yunpan.py file search "Photoshop" --parent-id 27172609 --max-depth 3 + +# 存储报告(按文件夹统计空间占用) +python3 scripts/123yunpan.py file storage-report --parent-id 0 + +# 获取文件详情 +python3 scripts/123yunpan.py file detail --file-id 12345 + +# 批量获取文件详情 +python3 scripts/123yunpan.py file details-multi --file-ids "123,456,789" + +# 创建文件夹 +python3 scripts/123yunpan.py file mkdir --name "新文件夹" --parent-id 0 + +# 目录树展示 +python3 scripts/123yunpan.py file tree --parent-id 0 + +# 重命名 +python3 scripts/123yunpan.py file rename --file-id 12345 --new-name "新名称.txt" + +# 批量重命名 +python3 scripts/123yunpan.py file rename-batch --file-ids 123 456 --new-names "a.txt" "b.txt" + +# 移动文件 +python3 scripts/123yunpan.py file move --file-ids "123,456" --target-parent-id 0 + +# 复制文件 +python3 scripts/123yunpan.py file copy --file-id 12345 --target-parent-id 0 + +# 移入回收站 +python3 scripts/123yunpan.py file trash --file-ids "123,456" + +# 从回收站恢复 +python3 scripts/123yunpan.py file recover --file-ids "123,456" + +# 下载文件 +python3 scripts/123yunpan.py file download --file-id 12345 --output /path/to/save + +# 批量下载文件 +python3 scripts/123yunpan.py file download-batch --file-ids "123,456,789" --output /path/to/save +``` + +### 上传 + +```bash +# 上传文件(自动选择单次/分片上传,自动检测秒传) +python3 scripts/123yunpan.py upload --file /path/to/file.txt --parent-id 0 + +# 上传并指定文件名 +python3 scripts/123yunpan.py upload --file /path/to/file.txt --parent-id 0 --filename "自定义名.zip" + +# 重复文件处理:1=重命名 2=覆盖(默认不传=覆盖) +python3 scripts/123yunpan.py upload --file /path/to/file.txt --parent-id 0 --duplicate 2 +``` + +秒传时会显示:`⚡ 秒传成功!文件已存在,跳过上传 (fileID: 12345)` + +### 分享管理 + +```bash +# 创建单个分享 +python3 scripts/123yunpan.py share create --name "分享名" --file-ids "123,456" --expire 0 + +# 分享导出(批量导出分享链接到 Excel) +python3 scripts/123yunpan.py share export --output shares.xlsx + +# ⭐ 查看空间占用(专业空间 / 标准空间) +python3 scripts/123yunpan.py space + +# ⭐ 批量创建分享(递归遍历文件夹,导出 Excel) +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --expire 0 -o result.xlsx + +# 批量创建分享(导出 JSON) +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 -o result.json + +# 超过 10 个链接自动生成 Excel;可用以下参数调整 +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --excel-threshold 50 +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --no-excel +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --resume # 断点续跑 + +# 列出分享 +python3 scripts/123yunpan.py share list --limit 10 +python3 scripts/123yunpan.py share list --all-pages + +# ⭐ 分享统计(查看下载量 Top N) +python3 scripts/123yunpan.py share stats --top 10 + +# 更新分享设置 +python3 scripts/123yunpan.py share update --share-ids "1,2" --traffic-switch 2 + +# 批量删除分享 +python3 scripts/123yunpan.py share delete-batch --share-ids "1,2,3" + +# 删除所有分享(跳过确认) +python3 scripts/123yunpan.py share delete-batch --all -y + +# 创建付费分享 +python3 scripts/123yunpan.py share pay-create --name "付费资源" --file-ids "123,456" --amount 10 + +# 列出付费分享 +python3 scripts/123yunpan.py share pay-list --limit 10 +``` + +### 直链管理 + +```bash +# 启用文件直链 +python3 scripts/123yunpan.py direct-link enable --file-id 12345 + +# 获取完整直链 +python3 scripts/123yunpan.py direct-link url --file-id 12345 + +# ⭐ 生成短直链(更隐私,不暴露文件名) +python3 scripts/123yunpan.py direct-link short-url --file-id 12345 +# 输出: https://1812749510.v.123pan.cn/1812749510/12345 + +# 刷新直链缓存 +python3 scripts/123yunpan.py direct-link cache-refresh --file-id 12345 + +# 禁用直链 +python3 scripts/123yunpan.py direct-link disable --file-id 12345 + +# IP 白名单管理 +python3 scripts/123yunpan.py direct-link ip-list +python3 scripts/123yunpan.py direct-link ip-switch --switch 1 +python3 scripts/123yunpan.py direct-link ip-update --ips "1.1.1.1" "2.2.2.2" + +# 流量日志 +python3 scripts/123yunpan.py direct-link traffic-logs --file-id 12345 +``` + +### 离线下载 + +```bash +# 创建离线下载任务 +python3 scripts/123yunpan.py offline create --url "https://example.com/file.zip" --parent-id 0 + +# 查询下载进度 +python3 scripts/123yunpan.py offline progress --task-id abc123 +``` + +### 图床 (OSS) + +```bash +python3 scripts/123yunpan.py oss list --parent-id 0 --limit 100 +python3 scripts/123yunpan.py oss detail --file-id 12345 +python3 scripts/123yunpan.py oss delete --file-id 12345 +python3 scripts/123yunpan.py oss move --file-id 12345 --target-parent-id 0 +python3 scripts/123yunpan.py oss copy --file-ids "123,456" --target-parent-id 0 +``` + +### 视频转码 + +```bash +python3 scripts/123yunpan.py transcode list --parent-file-id 0 --limit 100 +python3 scripts/123yunpan.py transcode resolutions --file-id 12345 +python3 scripts/123yunpan.py transcode transcode --file-id 12345 --resolution 1080p +python3 scripts/123yunpan.py transcode download --file-id 12345 --output /path/ +python3 scripts/123yunpan.py transcode download-m3u8 --file-id 12345 --output /path/ +``` + +### 其他 + +```bash +# 查看配置 +python3 scripts/123yunpan.py config + +# 验证配置有效性 +python3 scripts/123yunpan.py config check + +# 配置导出 +python3 scripts/123yunpan.py config export --output config.json + +# 配置导入 +python3 scripts/123yunpan.py config import --file config.json + +# 获取用户信息 +python3 scripts/123yunpan.py user-info + +# 解锁保险箱 +python3 scripts/123yunpan.py safebox unlock --password "your_password" + +# ⭐ 空间占用报告(专业空间 + 标准空间) +python3 scripts/123yunpan.py space +``` + +--- + +## 📄 分享 Excel 列结构 + +`share create-batch` 在**有效链接数 > 10** 时自动生成 Excel,固定三列: + +| 列名 | 说明 | 示例 | +|------|------|------| +| **文件名称** | 去分类前缀、去扩展名 | `Cinema 4D R20` | +| **链接** | 分享/直链 URL,可点击超链接 | `https://www.123pan.com/s/JyAKVv-o6bI` | +| **路径** | 云盘内完整相对路径(含文件名) | `软件/office/离线安装包/2021/ProPlus2021Retail.img` | + +名称解析规则:`Cinema 4D/Cinema 4D R20.zip` → 名称 `Cinema 4D R20`、路径 `Cinema 4D/Cinema 4D R20.zip`。 + +> xlsx 由 `zipfile` + 手写 OOXML 生成,**不需要安装 openpyxl**。 + +--- + +## ⚡ 限流保护 + +CLI 内置端点级 QPS 表,自动限速,无需手动 `sleep`: + +| 端点 | QPS | +|------|-----| +| `/api/v2/file/list` | **1.5**(最严格) | +| `/api/v1/user/info` | 10 | +| `/api/v1/access_token` | 10 | +| `/api/v1/share/create` | 3 | + +- **429 / 5xx** → 指数退避重试(最多 4 次) +- **401** → 自动刷新 Token 后重试一次 +- 错误码会翻译为「原因 + 处置建议」再打印,不会只丢裸码 + +--- + +## ⚠️ 注意事项 + +| 项目 | 说明 | +|------|------| +| 文件名限制 | 不能包含 `\\\"/:*?|><`,长度 < 256 字符 | +| 上传限制 | 单文件最大 10GB(API 限制) | +| 分享有效期 | 仅支持 0(永久)/ 1 / 7 / 30 天 | +| 分享名称长度 | 付费分享要求 < 35 字符,CLI 自动截断 | +| 多 ID 写法 | `--file-ids` / `--share-ids` 只接受一个值,多个 ID 要写成 `--file-ids "1,2,3"` | +| `--json` 位置 | 全局选项,必须写在子命令之前(`--json config check`) | +| transcode 参数 | 用 `--parent-file-id`,与 file 系列的 `--parent-id` 不同 | +| 空间双配额 | **专业空间 / 标准空间独立**,任一超额都阻断上传,先看 `space` | +| 审核驳回文件 | `status > 100` 无法分享,批量分享时自动剔除 | +| Token 刷新 | 过期时自动刷新,无需手动处理 | +| `fileIDList` 格式 | 必须是逗号分隔的**字符串**(`"123,456"`),不是数组 | +| 分享链接格式 | `https://www.123pan.com/s/{shareKey}` | +| 回收站 | v2 API 默认包含回收站文件,用 `--trashed` / `--show-trashed` 过滤 | +| Excel 阈值 | 默认超过 **10** 个链接自动生成,可用 `--excel-threshold` / `--no-excel` 调整 | +| `--no-color` | 禁用彩色输出,适用于日志记录和管道操作 | +| `--json` | 所有命令支持 `--json` 输出 JSON 格式,便于脚本解析 | + +--- + +## 📁 文件结构 + +``` +123yunpan/ +├── SKILL.md # AI 技能文档(完整 API 参考,含 Changelog) +├── README.md # 用户文档(本文件) +├── manifest.json # 技能清单(命令 / 参数 / 输出字段契约) +├── scripts/ +│ └── 123yunpan.py # CLI 主程序(纯 Python 标准库) +└── references/ + ├── bulk-operations.md # 批量操作指南 + └── competitor-upload-patterns.md # 上传模式参考 +``` + +--- + +## 📚 相关链接 + +- [123云盘开放平台文档](https://123yunpan.yuque.com/org-wiki-123yunpan-muaork/cr6ced) +- [123云盘官网](https://www.123pan.com) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..62b258b --- /dev/null +++ b/SKILL.md @@ -0,0 +1,746 @@ +--- +name: 123yunpan +description: "Use when interacting with 123 Cloud Drive (123云盘) via its open API. Supports file management, upload, download, sharing, offline download, direct links, image hosting, and video transcoding." +version: 1.3.0 +author: 暖心向阳335 +license: MIT +metadata: + hermes: + tags: [123yunpan, cloud-drive, file-management, api, storage] + related_skills: [bulk-downloading, resource-sharing-platform] +--- + +# 123云盘 (123 Cloud Drive) Skill + +## Agent Quick Start + +**脚本路径**: `scripts/123yunpan.py`(相对于本 skill 目录) +**首次使用**: 先跑 `config check` 确认凭证有效,再操作文件 +**输出格式**: 加 `--json` 获取单行 JSON,方便解析 +**文件 ID**: 所有操作都基于 `file_id`(数字),先 `file list` 或 `file search` 获取 ID + +**⚠️ 操作规则**: +1. **删除规则**: 用户要求删除文件时,必须先向用户展示文件列表(如"删除文件如下:A.txt、B.zip"),确认后再执行删除操作,删除后提醒"如需找回,请前往回收站恢复" +2. **下载规则**: 下载完成后告知用户保存位置,如"文件已保存到:/path/to/file.pdf" +3. **分享规则**: 分享少于 10 个文件时直接给出链接;**超过 10 个时导出 Excel 文件并发送给用户**(阈值可用 `--excel-threshold` 覆盖,或用 `--no-excel` 关闭自动导出) +4. **搜索规则**: 用户未给出具体云盘位置只给了文件名,搜索到多个匹配结果时,列出文件让用户选择,不要自行决定 +5. **批量规则**: 批量操作前先告知用户数量和预计耗时,如"共 150 个文件,预计需要 2 分钟" +6. **上传规则**: 用户未指定目标文件夹时,先列出根目录让用户选择上传位置 +7. **错误规则**: 遇到错误时给出解决方案,不要只报错。如"文件不存在"→提示检查 file_id 是否正确;"分享失败"→提示检查文件是否违规 + +``` +# 1. 验证配置(--json 是全局选项,必须写在子命令之前) +python3 scripts/123yunpan.py --json config check + +# 2. 查看根目录 +python3 scripts/123yunpan.py --json file list --parent-id 0 + +# 3. 搜索文件 +python3 scripts/123yunpan.py --json file search "关键词" --parent-id 0 + +# 4. 查看目录树(快速了解结构) +python3 scripts/123yunpan.py file tree --parent-id 0 --max-depth 2 +``` + +## Common Workflows + +### 🔍 "帮我找某个文件" +``` +1. file search "关键词" --parent-id 0 --json → 获取 file_id +2. file detail --file-id {id} --json → 确认文件信息 +3. file download --file-id {id} -o ./ → 下载 +4. 告知用户:文件已保存到:./xxx.pdf +``` + +### 🔗 "把这个文件夹分享出去" +``` +1. file tree --parent-id {folder_id} --max-depth 2 → 确认内容 +2. 少于 10 个:share create 逐个创建,直接给出链接 +3. 超过 10 个:share create-batch --parent-id {folder_id} --expire 0 -o links.xlsx → 导出 Excel +``` + +### ⬆️ "上传文件到云盘" +``` +1. 用户未指定位置 → file list --parent-id 0 --json → 列出根目录让用户选 +2. upload --file /path/to/file --parent-id {folder_id} +3. 告知用户:文件已上传到:{folder_id}/xxx.pdf +``` + +### ⬇️ "批量下载这些文件" +``` +1. file search "关键词" --parent-id {root} --json → 获取 file_id 列表 +2. file download-batch --file-ids "id1,id2,id3" -o ./downloads/ → 批量下载 +``` + +### 📊 "看看空间占用情况" +``` +1. space → 专业空间 / 标准空间明细(双配额) +2. file storage-report --parent-id 0 --top 10 → 按文件夹分析+大文件排行 +``` + +### 🗑️ "删除文件" +``` +1. file list --parent-id {folder_id} --search "关键词" --json → 找到 file_id +2. 向用户展示将删除的文件列表,格式:删除文件如下:XXX.txt、XXX.zip +3. file trash --file-ids "{id1},{id2}" → 移入回收站 +4. 提醒用户:如需找回,请前往回收站恢复 +``` + +> ⚠️ **删除操作必须先向用户展示文件列表,确认后再执行!** + +### 🔄 "把配置迁移到另一台机器" +``` +# 机器 A: +config export -o config.json + +# 机器 B: +config import --file config.json +config check → 验证导入成功 +``` + +## Overview + +This skill provides a complete CLI wrapper for the [123 Cloud Drive Open Platform API](https://123yunpan.yuque.com/org-wiki-123yunpan-muaork/cr6ced). It covers **70+ API endpoints** across authentication, file management, upload, download, sharing, offline download, direct links, image hosting, and video transcoding. + +## Prerequisites + +### 1. Purchase Developer Access Pack +Visit https://www.123pan.com/member and purchase a developer access pack. You will receive `client_id` and `client_secret` via in-app message. + +### 2. Configuration +The skill requires three credentials stored in environment variables: + +```bash +# Required for API authentication +export TWOPAN_CLIENT_ID="your_client_id_here" +export TWOPAN_CLIENT_SECRET="your_client_secret_here" +export TWOPAN_ACCESS_TOKEN="your_access_token_here" +``` + +**If these are not set when the skill is first used**, the CLI will output a clear reminder: + +``` +⚠️ 123云盘技能需要配置以下环境变量才能使用: + export TWOPAN_CLIENT_ID="your_client_id" + export TWOPAN_CLIENT_SECRET="your_client_secret" + export TWOPAN_ACCESS_TOKEN="your_access_token" + + 获取方式: + 1. 访问 https://www.123pan.com/member 购买开发者权益包 + 2. 通过 client_id + client_secret 调用 /api/v1/access_token 获取 access_token + 3. 将三个值设置为环境变量 + + 或运行: 123yunpan.py setup +``` + +### 3. Interactive Setup (Recommended) +Run `123yunpan.py setup` without arguments for interactive credential entry: +``` +$ python3 scripts/123yunpan.py setup +⚠️ 未检测到 client_id + 获取方式:访问 https://www.123pan.com/member 购买开发者权益包 +请输入 client_id: █ +⚠️ 未检测到 client_secret +请输入 client_secret: █ +正在获取 access_token... +✅ 配置已保存! +``` +If config already exists (from a previous setup or env vars), it skips prompting and uses the existing values. Non-interactive mode also works: `setup --client-id ID --client-secret SECRET`. + +## CLI Usage + +```bash +# === Global Options === +# Add --no-color to disable colored output, --json to output raw JSON + +# Setup / get token +python3 scripts/123yunpan.py setup --client-id YOUR_ID --client-secret YOUR_SECRET + +# Check config status +python3 scripts/123yunpan.py config +python3 scripts/123yunpan.py config check # 验证 token 有效性 +python3 scripts/123yunpan.py config export --output config.json +python3 scripts/123yunpan.py config import --file config.json + +# === File Management === +python3 scripts/123yunpan.py file list --parent-id 0 --limit 100 +python3 scripts/123yunpan.py file list --parent-id 0 --trashed # 只看回收站 +python3 scripts/123yunpan.py file list --parent-id 0 --show-trashed # 含回收站 +python3 scripts/123yunpan.py file search "Photoshop" --parent-id 27172609 --max-depth 3 +python3 scripts/123yunpan.py file tree --parent-id 0 --max-depth 3 +python3 scripts/123yunpan.py file storage-report --parent-id 0 --top 10 +python3 scripts/123yunpan.py file detail --file-id 12345 +python3 scripts/123yunpan.py file details-multi --file-ids "1,2,3" +python3 scripts/123yunpan.py file list --search "keyword" +python3 scripts/123yunpan.py file mkdir --name "New Folder" --parent-id 0 +python3 scripts/123yunpan.py file rename --file-id 12345 --new-name "renamed.txt" +python3 scripts/123yunpan.py file rename-batch --file-ids "1,2,3" --new-names "a.txt,b.txt,c.txt" +python3 scripts/123yunpan.py file move --file-ids "1,2,3" --target-parent-id 0 +python3 scripts/123yunpan.py file copy --file-id 12345 --target-parent-id 0 +python3 scripts/123yunpan.py file copy-batch --file-ids "1,2,3" --target-parent-id 0 +python3 scripts/123yunpan.py file copy-progress --task-id abc123 +python3 scripts/123yunpan.py file trash --file-ids "12345" +python3 scripts/123yunpan.py file recover --file-id 12345 +python3 scripts/123yunpan.py file recover-by-path --file-id 12345 --target-parent-id 0 --filename "restored.txt" +python3 scripts/123yunpan.py file download --file-id 12345 --output /path/to/save +python3 scripts/123yunpan.py file download-batch --file-ids "1,2,3" --output-dir /path/to/save + +# === Upload (V2 Recommended) === +python3 scripts/123yunpan.py upload --file /path/to/file.txt --parent-id 0 + +# === Space / User === +python3 scripts/123yunpan.py space # 空间占用报告(专业 + 标准,双配额) +python3 scripts/123yunpan.py --json space # 机器可读版本 +python3 scripts/123yunpan.py user-info + +# === Share Management === +python3 scripts/123yunpan.py share create --name "My Share" --file-ids "1,2,3" --expire 7 +# 超过 10 个链接自动生成 Excel(三列:文件名称 / 链接 / 路径) +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --expire 0 -o result.xlsx +# 覆盖自动导出阈值 / 关闭自动导出 / 断点续跑 +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --excel-threshold 50 +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --no-excel +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --resume +python3 scripts/123yunpan.py share list --limit 10 +python3 scripts/123yunpan.py share list --all-pages +python3 scripts/123yunpan.py share stats --top 10 +# 注意:--share-ids / --file-ids 只接受单个值,多个 ID 必须写成一个逗号分隔的字符串 +python3 scripts/123yunpan.py share delete-batch --share-ids "1,2,3" +python3 scripts/123yunpan.py share delete-batch --all -y # 删除全部(跳过确认) +python3 scripts/123yunpan.py share update --share-ids "1,2" --traffic-switch 2 +python3 scripts/123yunpan.py share export --output shares.xlsx +python3 scripts/123yunpan.py share export --output shares.json # 按扩展名自动判断格式 +python3 scripts/123yunpan.py share pay-create --name "Paid Share" --file-ids "1,2" --amount 10 +python3 scripts/123yunpan.py share pay-list --limit 10 +python3 scripts/123yunpan.py share pay-update --share-ids "1" --traffic-switch 1 + +# === Offline Download === +python3 scripts/123yunpan.py offline create --url "https://example.com/file.zip" --parent-id 0 +python3 scripts/123yunpan.py offline progress --task-id abc123 +python3 scripts/123yunpan.py offline list + +# === Direct Link === +python3 scripts/123yunpan.py direct-link enable --file-id 12345 +python3 scripts/123yunpan.py direct-link disable --file-id 12345 +python3 scripts/123yunpan.py direct-link url --file-id 12345 +python3 scripts/123yunpan.py direct-link short-url --file-id 12345 # 短直链(更隐私) +python3 scripts/123yunpan.py direct-link cache-refresh --file-id 12345 +python3 scripts/123yunpan.py direct-link ip-list +python3 scripts/123yunpan.py direct-link ip-switch --switch 1 +python3 scripts/123yunpan.py direct-link ip-update --ips "1.1.1.1,2.2.2.2" +python3 scripts/123yunpan.py direct-link offline-logs +python3 scripts/123yunpan.py direct-link traffic-logs + +# === Image Hosting (OSS) === +python3 scripts/123yunpan.py oss list --parent-id 0 --limit 100 +python3 scripts/123yunpan.py oss detail --file-id 12345 +python3 scripts/123yunpan.py oss delete --file-id 12345 +python3 scripts/123yunpan.py oss move --file-id 12345 --target-parent-id 0 +python3 scripts/123yunpan.py oss copy --file-ids "1,2,3" --target-parent-id 0 +python3 scripts/123yunpan.py oss copy-process --task-id abc123 +python3 scripts/123yunpan.py oss copy-fail --task-id abc123 + +# === Video Transcode === +# 注意:transcode 系列用 --parent-file-id,不是 --parent-id +python3 scripts/123yunpan.py transcode list --parent-file-id 0 --limit 100 +python3 scripts/123yunpan.py transcode folder --parent-file-id 0 +python3 scripts/123yunpan.py transcode resolutions --file-id 12345 +python3 scripts/123yunpan.py transcode transcode --file-id 12345 --resolution 1080p +python3 scripts/123yunpan.py transcode record --file-id 12345 +python3 scripts/123yunpan.py transcode result --file-id 12345 +python3 scripts/123yunpan.py transcode delete --file-id 12345 +python3 scripts/123yunpan.py transcode download --file-id 12345 --output /path/ +python3 scripts/123yunpan.py transcode download-m3u8 --file-id 12345 --output /path/ +python3 scripts/123yunpan.py transcode download-all --file-id 12345 --output /path/ +python3 scripts/123yunpan.py transcode upload-cloud --file-id 12345 --parent-id 0 + +# === Safebox === +python3 scripts/123yunpan.py safebox unlock --password "your_password" + +# === Cache Management === +python3 scripts/123yunpan.py cache list # 列出所有缓存文件 +python3 scripts/123yunpan.py cache clear # 清除全部缓存 +python3 scripts/123yunpan.py cache clear --root-id 12345 # 清除指定文件夹的缓存 +python3 scripts/123yunpan.py cache info # 显示缓存目录信息 +``` + +## JSON Output Fields (`--json`) + +加 `--json` 后所有命令输出单行 JSON,以下是主要命令的字段说明: + +### `file list --json` +```json +{"code":0, "data":{"fileList":[ + {"fileId":12345, "filename":"报告.pdf", "size":1048576, "type":0, + "parentFileID":0, "createAt":"2026-01-01T00:00:00Z", "trashed":0} +], "lastFileId":12345}} +``` +- `type`: 0=文件, 1=文件夹 +- `trashed`: 0=正常, 1=已删除 +- `size`: 字节数 + +### `file detail --json` +```json +{"fileId":12345, "filename":"报告.pdf", "size":1048576, + "md5":"abc123...", "type":0, "parentFileID":0, + "downloadUrl":"https://...", "createAt":"2026-01-01T00:00:00Z"} +``` + +### `space --json` +```json +{"nickname": "暖心向阳335", + "total_quota": 21474836480, "total_used": 9223372036854775807, + "professional": {"used": 1.5e10, "quota": 2.0e10, "remain_days": 0, + "permanent": true, "used_pct": 75.0}, + "standard": {"used": 1.2e9, "quota": 5.0e9, "remain_days": 0, + "permanent": true, "used_pct": 24.0}} +``` +- `professional` 满 → 开放平台上传 / 离线下载 / 转码入口被阻断 +- `remain_days` > 0 表示官方给的清理倒计时,超出后配额不可恢复 + +### `user-info --json` +```json +{"uid":1812749510, "nickname":"暖心向阳335", + "spaceUsed":9223372036854775807, "spacePermanent":9223372036854775807, + "vip":1} +``` +- `spaceUsed`/`spacePermanent`: 字节数,用 `human_size()` 转换 + +### `share create --json` +```json +{"code":0, "data":{"shareID":5193007, "shareKey":"JyAKVv-o6bI"}, + "_shareUrl":"https://www.123pan.com/s/JyAKVv-o6bI"} +``` +- `_shareUrl` 是 CLI 自动拼接的,不是 API 返回的 + +### `share list --json` +```json +{"code":0, "data":{"shareList":[ + {"shareId":5193007, "shareName":"分享", + "shareKey":"JyAKVv-o6bI", "shareExpire":7, + "previewCount":10, "downloadCount":5, "saveCount":2, + "createAt":"2026-01-01T00:00:00Z"} +], "lastShareId":5193007}} +``` + +### `upload --json` +```json +{"code":0, "data":{"fileID":12345, "completed":true, "reuse":false}} +``` +- `reuse`: true=秒传成功(文件已存在) + +### 通用规则 +- 所有 API 返回格式: `{"code":0, "message":"ok", "data":{...}}` +- `code=0` 表示成功,非 0 为错误 +- `file list` 和 `share list` 使用 `lastFileId`/`lastShareId` 做游标分页 +- 错误时返回: `{"code":错误码, "message":"错误描述"}` + +## API Reference Summary + +| Category | Endpoint | Method | +|----------|----------|--------| +| Cache | cache list | CLI | +| Cache | cache clear | CLI | +| Cache | cache info | CLI | +| Auth | `/api/v1/access_token` | POST | +| Auth (OAuth2) | `/api/v1/oauth2/access_token` | POST | +| File List (v2) | `/api/v2/file/list` | GET | +| File List (v1) | `/api/v1/file/list` | GET | +| File Detail | `/api/v1/file/detail` | GET | +| File Details (multi) | `/api/v1/file/infos` | POST | +| Create Dir | `/upload/v1/file/mkdir` | POST | +| Rename (single) | `/api/v1/file/name` | PUT | +| Rename (batch) | `/api/v1/file/rename` | POST | +| Move | `/api/v1/file/move` | POST | +| Copy (single) | `/api/v1/file/copy` | POST | +| Copy (batch) | `/api/v1/file/async/copy` | POST | +| Copy Progress | `/api/v1/file/async/copy/process` | GET | +| Trash | `/api/v1/file/trash` | POST | +| Recover | `/api/v1/file/recover` | POST | +| Recover (by path) | `/api/v1/file/recover/by_path` | POST | +| Download Info | `/api/v1/file/download_info` | GET | +| Upload V1 | `/upload/v1/file/create` | POST | +| Upload V2 | `/upload/v2/file/create` | POST | +| Upload Slice | `/upload/v2/file/slice` | POST | +| Upload Complete | `/upload/v2/file/upload_complete` | POST | +| Upload Domain | `/upload/v2/file/domain` | GET | +| SHA1 Reuse | `/upload/v2/file/sha1_reuse` | POST | +| Single Upload | `/upload/v2/file/single/create` | POST | +| Share Create | `/api/v1/share/create` | POST | +| Share List | `/api/v1/share/list` | GET | +| Share Delete | `/api/v1/share/delete` | POST | +| Share Update | `/api/v1/share/list/info` | PUT | +| Pay Share Create | `/api/v1/share/content-payment/create` | POST | +| Pay Share List | `/api/v1/share/payment/list` | GET | +| Pay Share Update | `/api/v1/share/list/payment/info` | PUT | +| Offline Download | `/api/v1/offline/download` | POST | +| Offline Progress | `/api/v1/offline/download/process` | GET | +| User Info | `/api/v1/user/info` | GET | +| Direct Link Enable | `/api/v1/direct-link/enable` | POST | +| Direct Link Disable | `/api/v1/direct-link/disable` | POST | +| Direct Link URL | `/api/v1/direct-link/url` | GET | +| Direct Link Cache | `/api/v1/direct-link/cache/refresh` | POST | +| IP Blacklist Switch | `/api/v1/developer/config/forbide-ip/switch` | POST | +| IP Blacklist Update | `/api/v1/developer/config/forbide-ip/update` | POST | +| IP Blacklist List | `/api/v1/developer/config/forbide-ip/list` | GET | +| Direct Link Logs | `/api/v1/direct-link/offline/logs` | GET | +| Direct Link Traffic | `/api/v1/direct-link/log` | GET | +| Image Upload Flow | `/upload/v1/oss/file/mkdir` | POST | +| Image Create Dir | `/upload/v1/oss/file/mkdir` | POST | +| Image Create | `/upload/v1/oss/file/create` | POST | +| Image Upload URL | `/upload/v1/oss/file/get_upload_url` | POST | +| Image Upload Done | `/upload/v1/oss/file/upload_complete` | POST | +| Image Upload Result | `/upload/v1/oss/file/upload_async_result` | POST | +| Image Copy | `/api/v1/oss/source/copy` | POST | +| Image Copy Process | `/api/v1/oss/source/copy/process` | GET | +| Image Copy Fail | `/api/v1/oss/source/copy/fail` | GET | +| Image Move | `/api/v1/oss/file/move` | POST | +| Image Delete | `/api/v1/oss/file/delete` | POST | +| Image Detail | `/api/v1/oss/file/detail` | GET | +| Image List | `/api/v1/oss/file/list` | POST | +| Image Offline | `/api/v1/oss/offline/download` | POST | +| Image Offline Process | `/api/v1/oss/offline/download/process` | GET | +| Video Upload Cloud | `/api/v1/transcode/upload/from_cloud_disk` | POST | +| Video Folder Info | `/api/v1/transcode/folder/info` | POST | +| Video Resolutions | `/api/v1/transcode/video/resolutions` | POST | +| Video Transcode List | `/api/v1/video/transcode/list` | GET | +| Video Transcode | `/api/v1/transcode/video` | POST | +| Video Record | `/api/v1/transcode/video/record` | POST | +| Video Result | `/api/v1/transcode/video/result` | POST | +| Video Delete | `/api/v1/transcode/delete` | POST | +| Video Download | `/api/v1/transcode/file/download` | POST | +| Video M3U8 Download | `/api/v1/transcode/m3u8_ts/download` | POST | +| Video All Download | `/api/v1/transcode/file/download/all` | POST | +| Safebox Unlock | `/api/v1/safebox/id` | GET | + +## Short Direct Link (短直链) + +除了分享链接和完整直链外,还可以生成**短直链** — 格式为 `https://{user_id}.v.123pan.cn/{user_id}/{file_id}`,不暴露文件名,更隐私友好。 + +### 生成短直链 +```bash +# CLI 命令 +python3 scripts/123yunpan.py direct-link short-url --file-id 12345 + +# 输出示例 +{ + "short_url": "https://1812749510.v.123pan.cn/1812749510/12345", + "user_id": 1812749510, + "file_id": 12345 +} +``` + +### 手动拼接(Python) +```python +import base64, json + +def get_user_id_from_token(token): + """从 JWT token 解析 user_id,无需额外 API 调用""" + parts = token.split(".") + payload = parts[1] + "=" * (4 - len(parts[1]) % 4) + return json.loads(base64.b64decode(payload)).get("id", 0) + +user_id = get_user_id_from_token(access_token) +short_url = f"https://{user_id}.v.123pan.cn/{user_id}/{file_id}" +``` + +### 三种链接格式对比 + +| 格式 | 示例 | 特点 | +|------|------|------| +| 分享链接 | `https://www.123pan.com/s/JyAKVv-o6bI` | 需要登录/跳转,最常用 | +| 完整直链 | `https://xxx.v.123pan.cn/xxx/xxx/filename.zip` | 直接下载,暴露文件名 | +| 短直链 | `https://1812749510.v.123pan.cn/1812749510/12345` | 直接下载,不暴露文件名 | + +> ⚠️ 短直链需要文件已在开启了直链功能的文件夹中,否则无法使用。 + +## Share URL Construction + +The share create API returns `shareID` and `shareKey` — it does **NOT** return a full `shareUrl`. You must construct the URL yourself. The CLI supports two formats via `--url-format`: + +| Format | CLI 参数 | URL 模式 | 示例 | +|--------|----------|----------|------| +| `short` (默认) | `--url-format short` | `https://www.123pan.com/s/{shareKey}` | `https://www.123pan.com/s/JyAKVv-o6bI` | +| `uid` | `--url-format uid` | `https://{user_id}.share.123pan.cn/123pan/{shareKey}` | `https://666666666.share.123pan.cn/123pan/JyAKVv-o6bI` | + +`uid` 格式自动从 JWT token 解析 `user_id`,无需额外 API 调用。解析失败时自动回退到 `short`。 + +```bash +# 默认 short 格式 +python3 scripts/123yunpan.py share create --name "分享" --file-ids 123,456 --expire 7 + +# UID 格式 +python3 scripts/123yunpan.py share create --name "分享" --file-ids 123,456 --url-format uid + +# 批量分享也支持 +python3 scripts/123yunpan.py share create-batch --parent-id 27172609 --url-format uid -o result.xlsx +``` + +Example response from `POST /api/v1/share/create`: +```json +{ + "code": 0, + "message": "ok", + "data": { + "shareID": 5193007, + "shareKey": "JyAKVv-o6bI" + } +} +``` + +## Direct Link & Short Direct Link + +The skill supports two types of direct links: + +**Full direct link** (contains filename in URL): +``` +https://{user_id}.v.123pan.cn/{user_id}/{file_id}/{filename} +``` + +**Short direct link** (privacy-friendlier, no filename): +``` +https://{user_id}.v.123pan.cn/{user_id}/{file_id} +``` + +To generate short direct links, parse `user_id` from the JWT access token (no extra API call needed): +```python +import base64, json +parts = token.split(".") +payload = parts[1] + "=" * (4 - len(parts[1]) % 4) +user_id = json.loads(base64.b64decode(payload))["id"] +short_link = f"https://{user_id}.v.123pan.cn/{user_id}/{file_id}" +``` + +> See `references/competitor-upload-patterns.md` for full upload workflow, SHA1 reuse, and WebDAV patterns. + +## 空间规则:专业空间 / 标准空间(双配额) + +根据官方 2026-09-24 公告,账号空间已拆分为**两套独立配额**,各有各的容量池与剩余天数: + +| 空间类型 | 存放内容 | 与开放平台的关系 | +|----------|----------|------------------| +| **专业空间** | 本人持有的文件;≥10GB 文件;**直链**文件;转码文件;图床文件 | **任一项超额都会阻断开放平台的上传 / 离线下载 / 转码入口** | +| **标准空间** | 转存(保存)的文件,<10GB;重复文件与回收站文件**不计入**容量 | 超额同样阻断上传 | + +用 `space` 命令查看明细,CLI 会自动做两件事: + +1. **使用率 ≥ 90%** → 红色预警,提示随时可能出现写入失败 +2. **专业空间剩余天数 > 0**(官方给定的清理倒计时,超出后配额不可恢复)→ 明确打印倒计时天数并标红 + +```bash +python3 scripts/123yunpan.py --json space # 机器可读:professional / standard / total 三段 +``` + +`config check` 与 `upload` 命令在执行前都会做空间预检,专业空间已满时会直接给出阻断提示,避免上传中途才失败。 + +> ⚠️ **排查要点**:上传失败、离线下载任务创建失败,先看是**专业空间**还是**标准空间**超限 —— 两者是独立的,不能互相顶替。 + +## 分享链接 Excel 导出(三列) + +`share create-batch` 在**有效链接数 > 阈值(默认 10)**时会自动生成 Excel 文件,列结构固定为三列: + +| 列名 | 含义 | 示例 | +|------|------|------| +| **文件名称** | 不含分类前缀、不含扩展名的干净文件名 | `Cinema 4D R20`(原 `Cinema 4D/Cinema 4D R20.zip`) | +| **链接** | 该文件的分享/直链 URL,单元格为可点击超链接 | `https://www.123pan.com/s/JyAKVv-o6bI` | +| **路径** | 文件在云盘内的完整相对路径(含文件名与扩展名) | `软件/office/离线安装包/2021/ProPlus2021Retail.img` | + +名称解析规则(`split_name_and_path()`): + +1. 去掉扩展名 → `Cinema 4D R20.zip` → `Cinema 4D R20` +2. 若去掉扩展名后的名字以「父目录名」开头,则补回父目录名避免歧义 + → `Cinema 4D/Cinema 4D R20.zip` → `Cinema 4D R20`(而不是 ` R20`) + +阈值控制: + +```bash +--excel-threshold N # 覆盖默认阈值(默认 10) +--no-excel # 关闭自动导出,超过阈值也只在终端打印链接 +--resume # 断点续跑:读取 ~/.123yunpan_cache/share_batch_{root_id}.json,只处理未完成的文件 +``` + +> ✅ **零依赖**:xlsx 由 `zipfile` + 手写 OOXML XML 直接生成,不再需要 `openpyxl`。任何 Python 3 标准库环境都能出文件,不会出现"静默降级成 JSON"的情况。 + +## 限流与重试(QPS 保护) + +官方开发须知明确给出各端点 QPS 上限,CLI 内置 `QPS_RULES` 表自动做节奏控制: + +| 端点 | QPS | +|------|-----| +| `/api/v2/file/list` | **1.5(最严格)** | +| `/api/v1/user/info` | 10 | +| `/api/v1/access_token` | 10 | +| `/api/v1/share/create` | 3 | +| 其他 | 默认 1.39(保守兜底) | + +配套行为: + +- **自动节流**:每个端点按自己的 QPS 计算最小请求间隔,超限时 sleep 等待 +- **指数退避重试**:HTTP 429 或 5xx → `2^attempt` 秒后重试,最多 4 次 +- **401 自动刷新 Token**:带 `client_id/secret` 时自动刷新后重试一次 +- **审核驳回文件过滤**:`status > 100` 的文件无法分享,遍历阶段直接剔除,避免逐个试错 + +## Recursive File Listing + +The `file list` API only returns **direct children** of a folder. To find all files in a multi-level directory tree, you must traverse recursively. See `references/bulk-operations.md` for BFS and layered traversal patterns. + +**Quick pattern — find all files under a root folder:** +```python +from collections import deque + +def find_all_files(root_id, list_folder_fn): + queue = deque([(root_id, [])]) + all_files = [] + while queue: + pid, path = queue.popleft() + for item in list_folder_fn(pid): + if item.get("trashed"): + continue + if item["type"] == 1: # folder + queue.append((item["fileId"], path + [item["filename"]])) + else: # file + all_files.append({ + "fileId": item["fileId"], + "filename": item["filename"], + "path": "/".join(path), + }) + return all_files +``` + +> ⚠️ Deep traversal can involve hundreds of API calls. For 100+ folders, expect 5-10 minutes. Use checkpoint/resume (save progress to JSON every N items) to handle interruptions. + +## Common Pitfalls + +1. **API response format** (CRITICAL): The 123yunpan API returns `{code: 0, message: "ok", data: {...}}` — NOT `{result: {...}}`. Always parse the `data` field. Use `client._extract(resp)` — the CLI script handles both formats. + +2. **Token expiry & auto-refresh**: access_token expires after a period. The CLI automatically refreshes on 401 responses using client_id/secret. For manual refresh, run `setup` or call `/api/v1/access_token`. + +3. **File name restrictions**: Filenames must be <256 chars and cannot contain: `\\\\\\\"/:*?|><` + +4. **Upload size**: Single file upload limit is 10GB via the API. + +5. **`fileIDList` must be a string** (CRITICAL): When calling the share create API directly, `fileIDList` must be a **comma-separated string** (`"123,456"`), NOT a JSON array (`[123, 456]`). Passing an array causes the API to return "文件列表不能为空". The CLI handles this internally via `",".join(str(x) for x in file_ids)`. Max 100 file IDs per share. `shareExpire` must be 0, 1, 7, or 30. + +6. **Share response has no `shareUrl`**: The share create response only returns `shareID` + `shareKey`. You must construct the URL. Use `--url-format short` (default, `https://www.123pan.com/s/{shareKey}`) or `--url-format uid` (`https://{user_id}.share.123pan.cn/123pan/{shareKey}`). See "Share URL Construction" section above. + +7. **Pagination**: Use `lastFileId`/`lastShareId` for cursor-based pagination. When value is `-1`, you've reached the last page. + +8. **Upload API domain routing** (CRITICAL): Upload endpoints are split across **two domains**: + - Main API domain (`https://open-api.123pan.com`): `/upload/v2/file/create` (chunked create), `/upload/v1/file/create` (v1 create) + - Upload domain (`https://openapi-upload.123242.com`): `/upload/v2/file/single/create` (single upload), `/upload/v2/file/slice` (chunked slice), `/upload/v2/file/upload_complete` (chunked complete) + - Upload domain is obtained via **GET** `/upload/v2/file/domain` — it returns a **list** `["https://..."]`, not an object with `host` field. + +9. **Single upload requires multipart/form-data** (CRITICAL): The `/upload/v2/file/single/create` endpoint does NOT accept JSON body. It requires `Content-Type: multipart/form-data` with fields `parentFileID`, `filename`, `etag`, `size` as text parts and the file content as a file part. Sending JSON results in a 500 server crash. + +10. **`duplicate` parameter** (CRITICAL): The `duplicate` field in upload create causes "duplicate参数错误" when set to `0` (skip). **Do not send `duplicate` at all** (defaults to overwrite), or use `2` (overwrite) or `1` (rename). The value `0` is rejected by the API. The CLI now defaults to not sending this parameter (overwrite behavior). + +11. **Trash behavior**: The v2 file list includes trashed files — filter by `trashed` field yourself. `trashed: 1` means the file is in trash. + +12. **Batch operations rate limit**: The CLI now enforces per-endpoint QPS automatically via the built-in `QPS_RULES` table (see "限流与重试" section) — do NOT add manual `time.sleep()` on top of it, or you'll halve the throughput. For very long batches (2500+ files) still use checkpoint/resume: `--resume` reads `~/.123yunpan_cache/share_batch_{root_id}.json` and skips already-done files. + +13. **Deep directory traversal**: Folders can be 4-5 levels deep. Use BFS with a queue for unknown structures, or layered traversal when structure is known. Each level requires a separate API call — 1400+ folders may take 10+ minutes. **Use BFS caching** (`~/.123yunpan_cache/`) to avoid repeated traversals — 57s → 0.15s on cache hit. CLI commands `file search` and `share create-batch` auto-use cache; add `--no-cache` to force refresh. + +14. **Common error responses**: + +| `code` | `message` | Meaning | +|--------|-----------|---------| +| `code` | HTTP | `message` | Meaning / 处理建议 | +|--------|------|-----------|--------------------| +| 0 | 200 | ok | Success | +| 1 | 200 | 文件列表不能为空 | `fileIDList` is empty or wrong format (must be comma-separated string) | +| 1 | 200 | duplicate参数错误 | `duplicate` field value is invalid (don't send `0`; use absent/`1`/`2`) | +| 1 | 200 | 分片MD5不能为空 | Slice MD5 missing — check field name matches API expectation | +| 1 | 200 | 该目录下文件名重复无法创建 | Filename already exists in target directory (use `--duplicate 2` to overwrite) | +| 2 | 200 | 分享ID非法 | File ID doesn't exist or has no share permission | +| 10 | 200 | 文件名已存在 | Target folder already has a file with that name | +| 16 | 200 | 文件不存在 | File ID not found | +| **20** | 200 | 分享文件涉嫌违规,禁止分享 | File content flagged by platform policy (e.g. `.alist` files) — CLI now filters `status > 100` files before sharing | +| **401** | 401 | — | access_token 无效或已过期 → 自动刷新重试一次;仍失败则 `setup` 重新获取 | +| **429** | 429 | — | 触发平台限流 → CLI 指数退避自动重试(最多 4 次),无需手动干预 | +| **5066** | 200 | — | 文件不存在(开放平台语义),检查 fileId | +| **5113** | 200 | — | 自用下载流量不足 → 升级 VIP 可获得无限流量下载 | + +> CLI 内置 `ERROR_HINTS` 表,以上错误码会以中文人话 + 处置建议的形式打印,不会只丢一个裸错误码。 + +15. **审核驳回文件无法分享 (CRITICAL)**: Files with `status > 100` are rejected by content review and can never be shared. `share create-batch` filters them out during traversal instead of failing one-by-one. If a file you expect to share silently disappears from the result, check its `status` field first. + +16. **专业空间 / 标准空间是两套独立配额 (CRITICAL)**: Per the 2026-09-24 announcement, exceeding **either** blocks open-platform uploads / offline downloads / transcoding. Standard space does **not** count duplicate files or trash files. Run `space` before a large upload to see which pool is actually full. + +17. **Excel 导出是三列结构,不是「文件名 + 链接」**: Columns are fixed as 文件名称 / 链接 / 路径. The name column strips the folder prefix and extension (`Cinema 4D/Cinema 4D R20.zip` → `Cinema 4D R20`), while 路径 keeps the full relative path with filename (`软件/office/离线安装包/2021/ProPlus2021Retail.img`). Don't re-derive these columns downstream — read them as-is. + +18. **Excel 导出零依赖,不要再去装 openpyxl**: xlsx is generated by hand via `zipfile` + OOXML XML (`write_xlsx()`). There is no `import openpyxl` anywhere in the script. Previously a missing openpyxl would silently degrade export to JSON — that can no longer happen. + +19. **Packaging & versioning**: Version must be updated in **two places**: (1) `scripts/123yunpan.py` `VERSION = "X.Y.Z"`, (2) `SKILL.md` frontmatter (`version: X.Y.Z`), plus (3) `manifest.json` (`version`). No `zip`/`unzip` CLI — use Python's `zipfile` module. Package includes SKILL.md + README.md + scripts/ + references/ (no LICENSE per user preference). Exclude `__pycache__/`. Extraction for patching: `python3 -c "import zipfile; zipfile.ZipFile('pkg.zip').extractall('_tmp')"`. Repackaging: `cd _tmp && python3 -c "import zipfile,os; [zipfile.ZipFile('out.zip','w',zipfile.ZIP_DEFLATED).write(f, os.path.relpath(f,'.')) for r,d,fs in os.walk('.') for f in fs if '__pycache__' not in r]"`. + +> ⚠️ Remember to also update the version in `scripts/123yunpan.py` `VERSION` when bumping the version in SKILL.md frontmatter and manifest.json. + +20. **Argparse parameter names must match SKILL.md exactly**: Before adding/changing argparse arguments, cross-check every `--flag` in SKILL.md CLI examples. Mismatches (e.g. `--name` in code vs `--new-name` in docs) silently break user workflows. Use `parse_int_list()` (not `type=int, nargs="+"`) for all file-ID arguments to support comma-separated input (`--file-ids "1,2,3"`). + - ⚠️ `--file-ids` / `--share-ids` / `--name` **只接受一个值**。多 ID 必须写成**一个带引号的逗号分隔字符串** `--file-ids "1,2,3"`;写成 `--file-ids 1 2 3` 会报 `unrecognized arguments: 2 3`,因为 `1 2 3` 会被当成多余的位置参数。 + - ⚠️ `--json` / `--no-color` 是**全局选项**,必须写在子命令**之前**(`--json config check`),写在后面报 `unrecognized arguments: --json`。 + - ⚠️ `transcode` 系列用 `--parent-file-id`,与 `file` 系列的 `--parent-id` **不是同一个参数**。 + - 可用 `.workbuddy/verify_doc_flags.py` 一键回归:它对文档里每一条 CLI 示例跑一遍 argparse 解析,找出「文档写了但代码没有」的选择器。 + +21. **`_refresh_token` uses imported names, not module paths**: The `ApiClient` methods use `Request` and `urlopen` (from `from urllib.request import ...`), NOT `urllib.request.Request`. Using the module path will crash with `NameError` since `urllib` is not imported as a module. + +22. **BFS traversal optional params**: `bfs_list_files()` accepts `max_depth`, `use_cache`, `max_age` — always forward these from callers (e.g. `cmd_file_search`). Unused parameters silently cause unbounded traversal. + +23. **Global options placement**: `--no-color` and `--json` must appear **before** the subcommand (e.g., `--json file list --parent-id 0`), not after. Placing them after the subcommand will cause argparse to ignore them. + +24. **`file tree` vs recursive traversal**: `file tree` is a display-only convenience command that shows directory structure. For bulk operations (downloading, moving, sharing), use recursive BFS traversal or `file list` with pagination — `file tree` does not return file IDs suitable for batch operations. + +25. **Share name is auto-truncated to 35 chars**: Paid shares require names < 35 characters; the CLI truncates automatically to avoid a 400 from the API. Don't pass a name longer than that expecting it round-trip. + +26. **`share export` format is inferred from the file extension**: `--output shares.xlsx` → Excel, `--output shares.json` → JSON. There is **no `--format` flag** on `share export` (passing it yields `unrecognized arguments`). Both go through the same zero-dependency `write_xlsx()` / JSON path. + +27. **`share pay-update` has no `--amount`**: Paid-share updates accept `--traffic-switch` / `--traffic-limit-switch` / `--traffic-limit` only. To change the price, recreate the share (or check the current API doc) rather than trying to patch `--amount`. + +28. **`config import` safety**: Importing config will overwrite your current `~/.123yunpan_config.json`. Back up your existing config before importing. The CLI warns before overwriting if the file exists. + +See `references/bulk-operations.md` for batch operation code patterns, BFS traversal, and background execution tips. + +## Verification Checklist + +- [ ] Environment variables `TWOPAN_CLIENT_ID`, `TWOPAN_CLIENT_SECRET`, `TWOPAN_ACCESS_TOKEN` are set +- [ ] Run `123yunpan.py setup` to validate credentials and fetch token +- [ ] Test with `123yunpan.py file list --parent-id 0 --limit 5` +- [ ] Confirm token refresh works by checking `~/.123yunpan_config.json` +- [ ] Run `123yunpan.py space` — two quotas (professional / standard) both reported +- [ ] Run `123yunpan.py share create-batch --parent-id {id}` with > 10 files → xlsx auto-generated, 3 columns correct +- [ ] Confirm no `openpyxl` dependency: `grep -c "import openpyxl" scripts/123yunpan.py` → `0`(脚本里只剩一句注释提到它) + +## Changelog + +### v1.3.0 +- **适配新空间规则**: 新增 `space` 命令,按官方 2026-09-24 公告拆分为**专业空间 / 标准空间**双配额展示;使用率 ≥90% 红色预警;专业空间剩余天数倒计时提示;`config check` 与 `upload` 增加空间预检 +- **改**: 分享链接 Excel 导出阈值 **20 → 10**(超过 10 个即自动生成 Excel),新增 `--excel-threshold` 覆盖与 `--no-excel` 关闭 +- **改**: Excel 列结构改为**三列** —— 文件名称(去分类前缀、去扩展名)/ 链接(超链接)/ 路径(云盘内完整相对路径) +- **新增**: `--resume` 断点续跑,读取 `~/.123yunpan_cache/share_batch_{root_id}.json` 只处理未完成文件 +- **重写**: Excel 写入器改为**零依赖**(`zipfile` + 手写 OOXML),彻底移除 `openpyxl` 依赖,不再有"缺库静默降级 JSON"的问题 +- **重构**: API 客户端加入 `QPS_RULES` 端点级限流(`/api/v2/file/list` = 1.5 QPS 最严格)+ 429/5xx 指数退避重试(最多 4 次)+ 401 自动刷新 Token 重试一次 +- **新增**: `ERROR_HINTS` 中文错误码映射(401 / 429 / 5066 / 5113 / 20 等),失败时给出原因与处置建议 +- **修复**: 分享名称超 35 字符导致 API 400 → 主动截断;`status > 100`(审核驳回)文件在遍历阶段过滤,不再逐个试错 + +### v1.2.0 (2026-05-09) +- **新增**: `--no-color` 和 `--json` 全局选项,支持禁用彩色输出和 JSON 格式输出 +- **新增**: `file tree` 命令 — 目录树展示,支持 `--parent-id` 和 `--max-depth` 参数 +- **新增**: `file download-batch` 命令 — 批量下载文件,支持 `--file-ids` 和 `--output-dir` 参数 +- **新增**: `file storage-report` 命令 — 存储分析,支持 `--parent-id` 和 `--top` 参数 +- **新增**: `offline list` 命令 — 列出离线下载任务(占位,待完善) +- **新增**: `share export` 命令 — 导出分享记录到 Excel/JSON,支持 `--output` 和 `--format` 参数 +- **新增**: `config export` 命令 — 导出配置到 JSON,支持 `--output` 参数 +- **新增**: `config import` 命令 — 从 JSON 导入配置,支持 `--file` 参数 + +### v1.1.5 (2026-05-09) +- **修复**: `upload` 命令的 `--duplicate` 参数默认值从 `0`(跳过)改为不发送(默认覆盖),避免API报错 +- **修复**: `_refresh_token` 方法使用了未导入的 `urllib.request` 模块,导致自动刷新Token时崩溃 +- **修复**: `file search --max-depth` 参数未实际生效,BFS遍历无深度限制 +- **修复**: 代码参数名与文档不一致(`--new-name`、`--target-parent-id`、`--ips`),统一到文档标准 +- **优化**: 所有 `--file-ids`/`--share-ids` 参数同时支持逗号分隔和空格分隔格式(如 `1,2,3` 或 `1 2 3`) +- **新增**: `cache list` 命令 - 列出所有BFS缓存文件 +- **新增**: `cache clear` 命令 - 清除缓存(支持指定文件夹或全部清除) +- **新增**: `cache info` 命令 - 显示缓存目录信息 +- **优化**: 批量操作进度显示增加ETA(预计剩余时间)估算 +- **优化**: 清理重复的import语句 + +### v1.1.4 +- 初始版本,支持70+ API端点 diff --git a/config.json b/config.json new file mode 100644 index 0000000..be5d26e --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "client_id": "YOUR_CLIENT_ID_HERE", + "client_secret": "YOUR_CLIENT_SECRET_HERE", + "access_token": "YOUR_ACCESS_TOKEN_HERE", + "default_folder_id": 0 +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7990a7d --- /dev/null +++ b/manifest.json @@ -0,0 +1,180 @@ +{ + "name": "123yunpan", + "version": "1.3.0", + "description": "123云盘开放平台 CLI 工具。支持文件管理、上传下载、分享链接、离线下载、直链、图床、视频转码等 70+ API 端点,专业/标准双空间配额报告,零依赖 Excel 导出。", + "entry": "scripts/123yunpan.py", + "config": { + "env": [ + { + "name": "TWOPAN_CLIENT_ID", + "required": true, + "description": "123云盘开发者 Client ID,从 https://www.123pan.com/member 购买开发者权益包获取" + }, + { + "name": "TWOPAN_CLIENT_SECRET", + "required": true, + "description": "123云盘开发者 Client Secret" + }, + { + "name": "TWOPAN_ACCESS_TOKEN", + "required": true, + "description": "123云盘 API Access Token(可通过 setup 命令自动获取)" + } + ] + }, + "security": { + "warnings": [ + "access_token 和 client_secret 是敏感凭证,不要硬编码在脚本中", + "批量删除操作(share delete-batch --all)会删除所有分享链接,请先用 --dry-run 预览", + "深度目录遍历(100+ 文件夹)可能耗时 5-10 分钟,使用 BFS 缓存可加速", + "专业空间或标准空间任一超额都会阻断开放平台上传,大批量上传前先跑 space 查看配额", + "审核驳回文件(status > 100)无法分享,share create-batch 会在遍历阶段自动剔除", + "WebDAV 上传有 30-90 秒索引延迟,上传后需轮询等待文件出现" + ], + "suggested_isolation": { + "config_file": "凭证存储在 ~/.123yunpan_config.json 或环境变量中", + "env_file": "推荐使用环境变量,不要在代码中硬编码" + } + }, + "commands": { + "config": { + "description": "配置管理", + "subcommands": ["show", "check", "export", "import"] + }, + "file": { + "description": "文件管理", + "subcommands": ["list", "search", "tree", "detail", "details-multi", "mkdir", "rename", "rename-batch", "move", "copy", "copy-batch", "copy-progress", "trash", "recover", "recover-by-path", "download", "download-batch", "storage-report"] + }, + "upload": { + "description": "上传文件(V2 API,自动单次/分片上传)", + "subcommands": [] + }, + "space": { + "description": "空间占用报告(专业空间 / 标准空间双配额)" + }, + "share": { + "description": "分享管理", + "subcommands": ["create", "create-batch", "list", "update", "stats", "delete-batch", "export", "pay-create", "pay-list", "pay-update"] + }, + "offline": { + "description": "离线下载", + "subcommands": ["create", "progress", "list"] + }, + "direct-link": { + "description": "直链管理", + "subcommands": ["enable", "disable", "url", "short-url", "cache-refresh", "ip-list", "ip-switch", "ip-update", "offline-logs", "traffic-logs"] + }, + "oss": { + "description": "图床管理", + "subcommands": ["list", "detail", "delete", "move", "copy", "copy-process", "copy-fail"] + }, + "transcode": { + "description": "视频转码", + "subcommands": ["list", "folder", "resolutions", "transcode", "record", "result", "delete", "download", "download-m3u8", "download-all", "upload-cloud"] + }, + "safebox": { + "description": "保险箱管理", + "subcommands": ["unlock"] + }, + "cache": { + "description": "BFS 缓存管理", + "subcommands": ["list", "clear", "info"] + }, + "user-info": { + "description": "获取用户信息" + } + }, + "global_options": { + "--no-color": "禁用彩色输出(也支持 NO_COLOR 环境变量)", + "--json": "JSON 机器可读输出(单行,无缩进)" + }, + "inputs": { + "file": { + "type": "string", + "description": "要上传的本地文件路径", + "required": false + }, + "parent-id": { + "type": "number", + "description": "目标文件夹 ID(默认 0 = 根目录)", + "required": false, + "default": 0 + }, + "file-ids": { + "type": "string", + "description": "文件 ID 列表(逗号分隔,如 1,2,3)", + "required": false + }, + "excel-threshold": { + "type": "number", + "description": "share create-batch 链接数超过该值时自动生成 Excel(默认 10)", + "required": false, + "default": 10 + }, + "url-format": { + "type": "string", + "description": "分享链接格式:official(默认) / web / uid / short", + "required": false, + "default": "official" + } + }, + "outputs": { + "file_list": { + "description": "文件列表 JSON", + "fields": { + "fileId": "文件 ID(数字)", + "filename": "文件名", + "size": "文件大小(字节)", + "type": "类型(0=文件, 1=文件夹)", + "trashed": "是否在回收站(0=否, 1=是)", + "parentFileID": "父文件夹 ID", + "createAt": "创建时间" + } + }, + "share_create": { + "description": "创建分享结果", + "fields": { + "shareID": "分享 ID", + "shareKey": "分享密钥", + "_shareUrl": "CLI 自动拼接的完整链接" + } + }, + "upload": { + "description": "上传结果", + "fields": { + "fileID": "文件 ID", + "completed": "是否完成", + "reuse": "是否秒传(文件已存在)" + } + }, + "user_info": { + "description": "用户信息", + "fields": { + "uid": "用户 ID", + "nickname": "昵称", + "spaceUsed": "已用空间(字节)", + "vip": "是否 VIP" + } + }, + "space_report": { + "description": "空间占用报告(专业 / 标准双配额)", + "fields": { + "total_quota": "总配额(字节)", + "total_used": "总已用(字节)", + "professional.used": "专业空间已用(本人文件 / ≥10GB / 直链 / 转码 / 图床)", + "professional.quota": "专业空间配额", + "professional.remain_days": "剩余清理天数,>0 时超出后配额不可恢复", + "standard.used": "标准空间已用(转存文件,重复与回收站文件不计)", + "standard.quota": "标准空间配额" + } + }, + "share_excel": { + "description": "分享链接 Excel 三列结构", + "columns": { + "文件名称": "不含分类前缀与扩展名,如 Cinema 4D R20", + "链接": "该文件的分享/直链 URL,单元格为可点击超链接", + "路径": "云盘内完整相对路径,如 软件/office/离线安装包/2021/ProPlus2021Retail.img" + } + } + } +} diff --git a/references/bulk-operations.md b/references/bulk-operations.md new file mode 100644 index 0000000..174ad28 --- /dev/null +++ b/references/bulk-operations.md @@ -0,0 +1,283 @@ +# 123云盘批量操作指南 + +## 批量创建分享链接 + +为大量文件创建分享链接时,需要注意速率限制和断点续传。 + +### ⚠️ 关键:`fileIDList` 必须是字符串 + +API 要求 `fileIDList` 为**逗号分隔的字符串**,不是数组: +```python +# ✅ 正确 +body = {"fileIDList": "12345,67890", "shareName": "xxx", "shareExpire": 0} + +# ❌ 错误 — 会返回 "文件列表不能为空" +body = {"fileIDList": [12345, 67890], "shareName": "xxx", "shareExpire": 0} +``` + +### 关键参数 +- API 端点: `POST /api/v1/share/create` +- 单次最多 100 个 fileID +- `shareExpire`: 0=永久, 1/7/30 天 +- 建议请求间隔: 0.5 秒(每秒不超过 2 个请求) + +### ⚠️ 分享链接构建 + +API 返回 `shareID` + `shareKey`,**不会返回完整 URL**,需要自行拼接。支持两种格式: + +```python +# Short 格式 (默认) +share_url = f"https://www.123pan.com/s/{share_key}" + +# UID 格式 — 需要 user_id(可从 JWT token 解析,无需额外 API) +share_url = f"https://{user_id}.share.123pan.cn/123pan/{share_key}" +``` + +响应示例: +```json +{"code": 0, "message": "ok", "data": {"shareID": 5193007, "shareKey": "JyAKVv-o6bI"}} +``` +→ Short: `https://www.123pan.com/s/JyAKVv-o6bI` +→ UID: `https://666666666.share.123pan.cn/123pan/JyAKVv-o6bI` + +### 完整示例:批量为文件创建分享并导出 Excel +```python +import json, time, urllib.request, urllib.parse +from collections import deque + +BASE = "https://open-api.123pan.com" + +def get_token(): + with open(os.path.expanduser("~/.123yunpan_config.json")) as f: + return json.load(f)["access_token"] + +def api_get(path, params=None): + url = BASE + path + if params: + url += "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {get_token()}", "Platform": "open_platform"}) + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + +def api_post(path, body): + url = BASE + path + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST", headers={ + "Authorization": f"Bearer {get_token()}", "Platform": "open_platform", + "Content-Type": "application/json"}) + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + +def list_folder(parent_id): + items, last_id = [], 0 + while True: + params = {"parentFileId": parent_id, "limit": 100} + if last_id: + params["lastFileId"] = last_id + resp = api_get("/api/v2/file/list", params) + data = resp.get("data", {}) + items.extend(data.get("fileList", [])) + last_id = data.get("lastFileId", -1) + if last_id == -1: + break + time.sleep(0.3) + return items + +def find_all_files(root_id): + queue, all_files = deque([(root_id, [])]), [] + while queue: + pid, path = queue.popleft() + for item in list_folder(pid): + if item.get("trashed"): + continue + if item["type"] == 1: + queue.append((item["fileId"], path + [item["filename"]])) + else: + all_files.append({"fileId": item["fileId"], + "filename": item["filename"], "path": "/".join(path)}) + return all_files + +def create_share(file_id, name, user_id=None): + """创建分享并返回 URL。user_id 非空时使用 UID 格式。""" + resp = api_post("/api/v1/share/create", { + "fileIDList": str(file_id), # ← 字符串,不是数组! + "shareName": name, "shareExpire": 0}) + data = resp.get("data", {}) + if resp.get("code") == 0: + key = data["shareKey"] + if user_id: + return f"https://{user_id}.share.123pan.cn/123pan/{key}" + return f"https://www.123pan.com/s/{key}" + return None + +# --- Usage --- +all_files = find_all_files(root_folder_id) +results = [] +for i, f in enumerate(all_files): + url = create_share(f["fileId"], f["filename"]) + results.append({"name": f["filename"], "url": url}) + time.sleep(0.6) + if (i + 1) % 10 == 0: + json.dump(results, open("progress.json", "w"), ensure_ascii=False) +``` + +### 断点续传模式 +```python +# 保存进度 +results_file = "/tmp/shares_progress.json" +if os.path.exists(results_file): + results = json.load(open(results_file)) + done_ids = {r["fileId"] for r in results if r.get("share_url")} + +# 跳过已完成的 +for item in all_files: + if item["fileId"] in done_ids: + continue + # ... 创建分享 ... + + # 每 N 条保存一次 + if done_count % 10 == 0: + json.dump(results, open(results_file, "w")) +``` + +### 速率控制 +```python +import time +time.sleep(0.5) # 每个请求后暂停 +# 每 5 个请求可额外暂停 +if (i + 1) % 5 == 0: + time.sleep(1) +``` + +## 短直链批量生成 + +为大量文件生成短直链(不暴露文件名),利用 JWT 解析 user_id,无需额外 API 调用: + +```python +import base64, json, time + +def get_user_id_from_token(token): + """从 JWT token 解析 user_id""" + parts = token.split(".") + payload = parts[1] + "=" * (4 - len(parts[1]) % 4) + return json.loads(base64.b64decode(payload)).get("id", 0) + +# 一次性获取 user_id +user_id = get_user_id_from_token(access_token) + +# 批量生成短直链 +for file_id in file_ids: + short_url = f"https://{user_id}.v.123pan.cn/{user_id}/{file_id}" + print(f"File {file_id}: {short_url}") +``` + +> ⚠️ 短直链要求文件在已开启直链功能的文件夹中。 + +## 深层目录遍历 + +123云盘文件夹可达 4-5 层深(学段/科目/版本/年级/文件)。 + +### BFS 遍历 + 缓存(推荐) + +遍历大量文件夹时,API 调用次数 = 文件夹数量。100+ 文件夹需要 5-10 分钟。 +**关键优化:将遍历结果缓存到 JSON,5 分钟内复用,跳过所有 API 调用。** + +实测效果:357 个文件遍历从 **57 秒 → 0.15 秒**(380 倍加速)。 + +```python +import os, json, time +from collections import deque + +CACHE_DIR = os.path.expanduser("~/.123yunpan_cache") + +def bfs_with_cache(client, root_id, max_age=300): + """BFS 遍历 + JSON 缓存""" + cache_path = os.path.join(CACHE_DIR, f"files_{root_id}.json") + os.makedirs(CACHE_DIR, exist_ok=True) + + # 尝试加载缓存(默认 5 分钟有效) + if os.path.exists(cache_path): + age = time.time() - os.path.getmtime(cache_path) + if age < max_age: + with open(cache_path) as f: + data = json.load(f) + print(f"使用缓存 ({int(age)}秒前, {len(data['files'])} 个文件)") + return data["files"] + + # BFS 遍历 + queue = deque([(root_id, [])]) + all_files = [] + while queue: + pid, path = queue.popleft() + items = list_folder(pid) # 自动翻页 + for item in items: + if item.get("trashed"): + continue + if item["type"] == 1: + queue.append((item["fileId"], path + [item["filename"]])) + else: + all_files.append({ + "fileId": item["fileId"], + "filename": item["filename"], + "path": "/".join(path), + "size": item.get("size", 0), + }) + + # 保存缓存 + with open(cache_path, "w") as f: + json.dump({"files": all_files, "ts": time.time()}, f, ensure_ascii=False) + + return all_files +``` + +> 缓存路径: `~/.123yunpan_cache/files_{root_id}.json` +> 强制刷新: 删除缓存文件或传 `max_age=0` + +### 分层遍历(已知结构时更快) +```python +# 已知 学段/科目/版本/年级/文件 结构 +for stage in list_folder(root_id): + for subject in list_folder(stage["fileId"]): + for version in list_folder(subject["fileId"]): + for grade in list_folder(version["fileId"]): + files = list_folder(grade["fileId"]) +``` + +### 翻页处理 +```python +def list_folder(parent_id): + items = [] + last_id = 0 + while True: + params = {"parentFileId": parent_id, "limit": 100} + if last_id: + params["lastFileId"] = last_id + resp = api_get("/api/v2/file/list", params) + data = _extract(resp) # v1.2.0: always use _extract() for {code,data} wrappers + items.extend(data.get("fileList", [])) + last_id = data.get("lastFileId", -1) + if last_id == -1: + break + return items +``` + +## 后台长时间运行 + +批量操作(如 2500+ 文件)需要 20+ 分钟,应在后台运行: + +```bash +python3 -u script.py 2>&1 | tee /tmp/progress.log +``` + +监控进度: +```bash +tail -5 /tmp/progress.log +``` + +## 环境限制:pip 不可用时安装 Python 包 + +系统 Python 受限(externally managed),用临时 venv: +```bash +uv venv /tmp/xlsx_env +uv pip install openpyxl --python /tmp/xlsx_env/bin/python +/tmp/xlsx_env/bin/python your_script.py +``` diff --git a/references/competitor-upload-patterns.md b/references/competitor-upload-patterns.md new file mode 100644 index 0000000..f7af8ee --- /dev/null +++ b/references/competitor-upload-patterns.md @@ -0,0 +1,124 @@ +# 123云盘上传模式 & 竞品分析 + +> 来源:分析 Skillhub 上 `123pan-upload` skill 的实现,提取可借鉴的模式。 + +## 短直链生成(Short Direct Link) + +直链 URL 包含 user_id 和文件路径,较长且暴露用户信息。短直链只用 file_id,更隐私友好: + +```python +import base64, json + +def get_user_id_from_token(token: str) -> int: + """从 JWT token 解析 user_id,无需额外 API 调用""" + parts = token.split(".") + if len(parts) >= 2: + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + data = json.loads(base64.b64decode(payload)) + return data.get("id", 0) + return 0 + +def get_short_direct_link(user_id: int, file_id: int) -> str: + return f"https://{user_id}.v.123pan.cn/{user_id}/{file_id}" +``` + +**对比:** +| 类型 | URL 格式 | 隐私性 | 可直链下载 | +|------|---------|--------|-----------| +| 分享链接 | `https://www.123pan.com/s/{shareKey}` | ✅ 好 | ❌ 需跳转 | +| 完整直链 | `https://{user_id}.v.123pan.cn/{user_id}/{file_id}/{filename}` | ⚠️ 暴露路径 | ✅ | +| 短直链 | `https://{user_id}.v.123pan.cn/{user_id}/{file_id}` | ⚠️ 暴露 user_id | ✅ | + +## 上传流程(V2 API) + +> ⚠️ **2026-05 验证**:以下流程经过实际 API 调用验证,修正了早期文档中的错误。 + +### 两个域名 + +| 域名 | 用途 | 端点 | +|------|------|------| +| `https://open-api.123pan.com` | 主 API(认证、文件管理、chunked create) | `/api/v1/*`, `/api/v2/*`, `/upload/v2/file/create` | +| `https://openapi-upload.123242.com` | 上传专用(获取上传域名返回此值) | `/upload/v2/file/single/create`, `/upload/v2/file/slice`, `/upload/v2/file/upload_complete` | + +获取上传域名:`GET /upload/v2/file/domain` → 返回 `["https://openapi-upload.123242.com"]`(列表,非对象) + +### 单文件上传(<100MB) + +``` +1. GET open-api.123pan.com/upload/v2/file/domain → 获取上传服务器地址 +2. POST openapi-upload.123242.com/upload/v2/file/single/create → multipart/form-data 上传 +``` + +**关键坑点**: +- `single/create` **必须用 multipart/form-data**,不能用 JSON body。JSON 会导致 500 服务器崩溃。 +- 表单字段:`parentFileID`(text), `filename`(text), `etag`(text), `size`(text), `file`(file) +- `duplicate` 字段:**不要传 `0`**(会导致 "duplicate参数错误")。不传默认覆盖,或传 `1`(重命名)/`2`(覆盖)。 +- 响应:`{"code":0, "data":{"fileID": 12345, "completed": true}}` — 无需额外上传步骤。 + +### 分片上传(100MB-10GB) + +``` +1. POST open-api.123pan.com/upload/v2/file/create → 创建上传任务(主域名) +2. POST openapi-upload.123242.com/upload/v2/file/slice → 逐片上传(上传域名) +3. POST openapi-upload.123242.com/upload/v2/file/upload_complete → 完成上传(上传域名) +``` + +**关键坑点**: +- `create` 调用主 API 域名,不是上传域名! +- `slice` 和 `upload_complete` 调用上传域名。 +- `slice` 请求体字段名为 `partMd5`(注意:不是 `sliceMD5`,后者会导致 500)。 +- 分片大小由 `create` 响应中的 `sliceSize` 决定(通常 16MB)。 + +### SHA1 秒传 +```python +# create_file 响应中 reuse=true 表示文件已存在,跳过上传 +if create_result["reuse"]: + return {"file_id": create_result["file_id"], ...} +``` + +### 上传校验轮询 +```python +# upload_complete 响应中 completed=false 时需轮询 +# 建议间隔:1 秒 +# 最大等待:~5 分钟/GB(10GB 文件约 50 分钟) +# 文件大小越大,校验时间越长 +for attempt in range(max_retries): + resp = api_post("/upload/v2/file/upload_complete", {"preuploadID": preupload_id}) + if resp["data"].get("completed"): + return resp["data"] # 校验完成 + time.sleep(1) # 按 API 文档要求,每秒轮询一次 +``` + +### MD5 作为 Etag +上传创建时需要提供文件的 MD5 作为 `etag` 字段: +```python +import hashlib +etag = hashlib.md5(open(file_path, "rb").read()).hexdigest() +``` + +## WebDAV 上传(>10GB 文件) + +API 上传上限 10GB,超大文件可用 WebDAV + rclone: + +```bash +# 1. 配置 rclone(隔离配置,避免读取其他云服务凭证) +rclone config --config ~/123pan-rclone.conf +# 添加 "123pan-webdav" remote + +# 2. 上传 +RCLONE_CONFIG=~/123pan-rclone.conf rclone copy /path/to/large/file 123pan-webdav:/folder -P + +# 3. 上传后通过 API 查找文件(WebDAV 上传有 30-90 秒索引延迟) +# 需要轮询 file/list 接口等待文件出现 +``` + +**注意:** WebDAV 上传后文件不会立即出现在 API 列表中,需要重试查找(最多 30 次,每次间隔 3 秒)。 + +## 安全实践 + +- rclone 配置隔离:用 `RCLONE_CONFIG` 环境变量指定独立配置文件 +- 凭证存储:用环境变量或 `.env` 文件,不要硬编码 +- Token 刷新:123yunpan.py CLI 已自动处理 401 响应时的 token 刷新 diff --git a/scripts/123yunpan.py b/scripts/123yunpan.py new file mode 100644 index 0000000..28ec44a --- /dev/null +++ b/scripts/123yunpan.py @@ -0,0 +1,3406 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +123云盘 (123 Cloud Drive) 开放平台 API 命令行工具 v1.2.1 +支持全部 70+ API 端点:文件管理、上传、分享、离线下载、直链、图床、视频转码、保险箱等 +仅使用 Python 标准库,无需安装第三方依赖。 +""" + +import os +import sys +import json +import hashlib +import math +import argparse +import mimetypes +import time +import re +import base64 +import zipfile +from urllib.request import Request, urlopen, build_opener, HTTPCookieProcessor +from urllib.parse import urlencode, quote +from urllib.error import HTTPError, URLError +from http.cookiejar import CookieJar + +# ─── 常量 ─────────────────────────────────────────────────────────────────── +VERSION = "1.3.0" +BASE_URL = "https://open-api.123pan.com" +CONFIG_PATH = os.path.expanduser("~/.123yunpan_config.json") +CACHE_DIR = os.path.expanduser("~/.123yunpan_cache") +DEFAULT_LIMIT = 100 # API 硬性上限:limit 最大 100 +MAX_LIMIT = 100 +SLICE_SIZE = 10 * 1024 * 1024 # 10MB 分片 + +# 超过该数量的分享链接不再逐个打印,改为生成 Excel +SHARE_EXCEL_THRESHOLD = 10 +# 付费分享名称长度限制(普通分享同样受此约束,主动截断避免 400 错误) +SHARE_NAME_MAX_LEN = 35 +# 单次分享最多拼接的文件 ID 数 +SHARE_MAX_FILE_IDS = 100 + +# 每次请求最小间隔(秒)。QPS 上限对应最小间隔 = 1/QPS,这里留出安全余量。 +MIN_REQUEST_INTERVAL = 0.72 # 约 1.4 次/秒,低于 v2/file/list 的 1.5 QPS 限流 + +NO_COLOR = os.environ.get("NO_COLOR") is not None # https://no-color.org/ +JSON_OUTPUT = False # --json 模式 + +# ─── QPS 限流表(来源:官方「开发须知」) ───────────────────────────────────── +# 未列出的端点使用上面的通用最小间隔。 +QPS_RULES = { + "/api/v2/file/list": 1.5, # 最严格,BFS 遍历时的瓶颈 + "/api/v1/file/list": 10, + "/api/v1/user/info": 10, + "/api/v1/file/move": 2, + "/api/v1/file/trash": 10, + "/api/v1/access_token": 10, + "/api/v1/share/create": 3, + "/api/v1/share/list": 3, + "/api/v1/share/delete": 3, + "/upload/v1/file/mkdir": 20, + "/api/v1/transcode/folder/info": 20, + "/api/v1/transcode/upload/from_cloud_disk": 1, + "/api/v1/transcode/delete": 10, + "/api/v1/transcode/video/resolutions": 1, + "/api/v1/transcode/video": 3, + "/api/v1/transcode/video/record": 20, + "/api/v1/transcode/video/result": 20, + "/api/v1/transcode/file/download": 10, + "/api/v1/transcode/m3u8_ts/download": 20, + "/api/v1/transcode/file/download/all": 1, +} + +# ─── 错误码中文说明 ────────────────────────────────────────────────────────── +# key: (message, 处理建议) —— 未命中时回退到原始 message +ERROR_HINTS = { + 401: ("access_token 无效或已过期", "重新执行 setup 获取新 token"), + 429: ("请求太频繁,触发限流", "降低调用频率后重试,或稍等几秒"), + 5113: ("自用下载流量不足", "升级 VIP 可获得无限流量下载"), + 5066: ("文件不存在", "检查传入的 fileId 是否正确"), + 1: ("参数错误", "检查请求参数格式,详见接口文档"), + 2: ("分享ID非法", "文件不存在或没有分享权限"), + 10: ("文件名已存在", "换一个文件名,或用 --duplicate 2 覆盖"), + 16: ("文件不存在", "file_id 错误或文件已被彻底删除"), + 20: ("分享文件涉嫌违规,禁止分享", "文件内容被平台策略拦截,换一个文件分享"), +} + + +def error_hint(code, message=""): + """把 API 错误码翻译成人话,附加处理建议。""" + if code in ERROR_HINTS: + msg, hint = ERROR_HINTS[code] + return f"{message or msg}({hint})" + return message or f"请求失败(code={code})" + + +# ─── 颜色控制 ──────────────────────────────────────────────────────────────── +class _AnsiStripper: + """NO_COLOR 时自动剥离 ANSI 转义序列的输出流包装""" + def __init__(self, stream): + self._stream = stream + + def write(self, text): + if NO_COLOR and isinstance(text, str): + text = re.sub("\x1b\\[[0-9;]*m", "", text) + self._stream.write(text) + + def flush(self): + self._stream.flush() + + def __getattr__(self, name): + return getattr(self._stream, name) + + +if NO_COLOR: + sys.stdout = _AnsiStripper(sys.stdout) + sys.stderr = _AnsiStripper(sys.stderr) + + +# ─── 工具函数 ─────────────────────────────────────────────────────────────── +def parse_int_list(value): + """Parse comma-separated or space-separated integers. + Supports: --file-ids 1,2,3 | --file-ids "1,2,3" | --file-ids 1 2 3 + """ + if isinstance(value, list): + result = [] + for v in value: + result.extend(parse_int_list(v)) + return result + return [int(x.strip()) for x in str(value).replace(" ", ",").split(",") if x.strip()] + + +def human_size(nbytes): + """将字节数转换为人类可读的文件大小""" + if nbytes is None: + return "未知" + try: + nbytes = int(nbytes) + except (ValueError, TypeError): + return str(nbytes) + if nbytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB", "PB"] + i = int(math.floor(math.log(nbytes, 1024))) if nbytes > 0 else 0 + if i >= len(units): + i = len(units) - 1 + val = nbytes / (1024 ** i) + return f"{val:.2f} {units[i]}" + + +def _colorize(text, code): + """根据 NO_COLOR 设置决定是否着色""" + if NO_COLOR: + return text + return f"\033[{code}m{text}\033[0m" + + +def print_json(data, extra_info=None): + """格式化打印 JSON 数据,可附带额外信息""" + if JSON_OUTPUT: + print(json.dumps(data, ensure_ascii=False)) + return + if extra_info: + print(_colorize(extra_info, "36")) + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def print_error(msg): + """打印错误信息""" + print(f"{_colorize('❌ 错误:', '31')}{msg}", file=sys.stderr) + + +def print_success(msg): + """打印成功信息""" + print(f"{_colorize('✅ ', '32')}{msg}") + + +def print_warn(msg): + """打印警告信息""" + print(f"{_colorize('⚠️ ', '33')}{msg}") + + +# status > 100 表示文件审核驳回(官方字段说明),这类文件无法分享 +def _is_rejected(item): + return int(item.get("status", 0) or 0) > 100 + + +def md5_file(path): + """计算文件的 MD5""" + h = hashlib.md5() + with open(path, "rb") as f: + while True: + chunk = f.read(8192) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +# ─── BFS 缓存 ──────────────────────────────────────────────────────────────── +def _cache_path(root_id): + """获取缓存文件路径""" + os.makedirs(CACHE_DIR, exist_ok=True) + return os.path.join(CACHE_DIR, f"files_{root_id}.json") + + +def _cache_load(root_id, max_age=300): + """加载缓存(默认 5 分钟有效)""" + path = _cache_path(root_id) + if not os.path.exists(path): + return None + age = time.time() - os.path.getmtime(path) + if age > max_age: + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + print(_colorize(f"📂 使用缓存 ({int(age)}秒前, {len(data['files'])} 个文件)", "36")) + return data + except Exception: + return None + + +def _cache_save(root_id, files, folders): + """保存缓存""" + path = _cache_path(root_id) + with open(path, "w", encoding="utf-8") as f: + json.dump({"files": files, "folders": folders, "ts": time.time()}, f, ensure_ascii=False) + + +def bfs_list_files(client, root_id, use_cache=True, max_age=300, show_progress=True, max_depth=None): + """BFS 遍历文件夹,带缓存支持。max_depth=None 表示不限深度。""" + from collections import deque + + # 尝试加载缓存 + if use_cache: + cached = _cache_load(root_id, max_age) + if cached: + return cached["files"], cached["folders"] + + if show_progress: + depth_info = f" (最大深度: {max_depth})" if max_depth else "" + print(_colorize(f"📂 正在遍历文件夹 {root_id}{depth_info}...", "36")) + + queue = deque([(root_id, [], 0)]) # (file_id, path, depth) + all_files = [] + all_folders = [] + folder_count = 0 + + while queue: + pid, path, depth = queue.popleft() + + # 深度限制:如果当前深度 >= max_depth,不再进入子文件夹 + if max_depth is not None and depth >= max_depth: + continue + + last_id = 0 + while True: + params = {"parentFileId": pid, "limit": 100} + if last_id: + params["lastFileId"] = last_id + resp = client._get("/api/v2/file/list", params=params) + data = client._extract(resp) + if not data: + break + for item in data.get("fileList", []): + if item.get("trashed"): + continue + if item.get("type") == 1: # 文件夹 + queue.append((item["fileId"], path + [item["filename"]], depth + 1)) + all_folders.append({ + "fileId": item["fileId"], + "filename": item["filename"], + "path": "/".join(path), + }) + folder_count += 1 + else: # 文件 + all_files.append({ + "fileId": item["fileId"], + "filename": item.get("filename", ""), + "path": "/".join(path), + "size": item.get("size", 0), + "status": item.get("status", 0), + }) + last_id = data.get("lastFileId", -1) + if last_id == -1 or not last_id: + break + + if show_progress and folder_count % 50 == 0 and folder_count > 0: + print(f" 已扫描 {folder_count} 个文件夹,找到 {len(all_files)} 个文件...", flush=True) + + if show_progress: + print(f"\033[36m📁 扫描完成: {folder_count} 个文件夹, {len(all_files)} 个文件\033[0m") + + # 保存缓存 + _cache_save(root_id, all_files, all_folders) + + return all_files, all_folders + + +def progress_bar(current, total, prefix="", suffix="", width=40, start_time=None): + """显示下载/上传进度条,支持ETA估算""" + if total <= 0: + return + pct = current / total + filled = int(width * pct) + bar = "█" * filled + "░" * (width - filled) + + # ETA计算 + eta_str = "" + if start_time and current > 0: + elapsed = time.time() - start_time + if elapsed > 0 and pct > 0: + remaining = elapsed * (1 - pct) / pct + if remaining >= 3600: + eta_str = f" ETA:{int(remaining // 3600)}h{int((remaining % 3600) // 60)}m" + elif remaining >= 60: + eta_str = f" ETA:{int(remaining // 60)}m{int(remaining % 60)}s" + else: + eta_str = f" ETA:{int(remaining)}s" + + print(f"\r{prefix} [{bar}] {pct*100:.1f}% {suffix}{eta_str}", end="", flush=True) + if current >= total: + print() + + +# ─── 零依赖 Excel (xlsx) 写入器 ─────────────────────────────────────────────── +# xlsx 本质是一个 zip 包 + XML,用标准库即可生成,无需 openpyxl。 +# 这样在任何没有安装第三方库的环境里都能稳定导出 Excel。 +_XML_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") + + +def _xml_escape(text): + """转义为合法 XML 文本,并剔除 XML 1.0 不允许的控制字符。""" + text = _XML_ILLEGAL.sub("", str(text)) + return (text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + +def _xml_attr(text): + """属性值版本(额外转义引号,XML 属性用双引号包裹)。""" + return _xml_escape(text).replace("'", "'") + + +def _col_letter(idx): + """1 -> A, 27 -> AA""" + letters = "" + while idx > 0: + idx, rem = divmod(idx - 1, 26) + letters = chr(65 + rem) + letters + return letters + + +def write_xlsx(path, sheet_name, headers, rows, widths=None, hyperlink_cols=()): + """写出一个最小可用的 .xlsx 文件。 + + headers: 表头列表 + rows: 二维列表,每行长度与 headers 一致 + widths: 列宽列表(可选) + hyperlink_cols: 需要做成可点击超链接的列下标(从 0 开始) + 返回写入的行数(不含表头) + """ + n_cols = len(headers) + n_rows = len(rows) + sheet_name = _xml_attr(sheet_name[:31]) or "Sheet1" + + # ── 单元格样式索引 ────────────────────────────────────────────────────── + S_DEFAULT, S_HEADER, S_ZEBRA, S_LINK, S_CELL = 0, 1, 2, 3, 4 + + parts = [] + + # [Content_Types].xml + parts.append('' + '' + '' + '' + '' + '' + '' + '') + + # _rels/.rels + parts.append('' + '' + '' + '') + + # xl/workbook.xml + parts.append('' + '' + f'' + '') + + # xl/_rels/workbook.xml.rels + parts.append('' + '' + '' + '' + '') + + # ── 样式表 ────────────────────────────────────────────────────────────── + style = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + f'' + '' + f'' + '' + f'' + '' + f'' + '' + f'' + '' + '' + '' + '' + '' + ) + parts.append(style) + + # ── 工作表 ────────────────────────────────────────────────────────────── + last_col = _col_letter(max(n_cols, 1)) + sheet = ['', + '', + f'', + '', + ''] + + if widths: + sheet.append("") + for i, w in enumerate(widths[:n_cols], 1): + sheet.append(f'') + sheet.append("") + + sheet.append("") + + # 表头行 + sheet.append(f'') + for c, h in enumerate(headers, 1): + ref = f"{_col_letter(c)}1" + sheet.append(f'' + f'{_xml_escape(h)}') + sheet.append("") + + # 数据行 + for r_i, row in enumerate(rows, start=2): + zebra = "1" if r_i % 2 == 0 else "0" + sheet.append(f'') + for c_i, value in enumerate(row[:n_cols]): + if value is None or value == "": + sheet.append(f'') + continue + is_hyperlink = c_i in hyperlink_cols and isinstance(value, str) and value.startswith("http") + if is_hyperlink: + style_idx = S_LINK + else: + style_idx = S_ZEBRA if zebra == "1" else S_CELL + + ref = f"{_col_letter(c_i + 1)}{r_i}" + if is_hyperlink: + # 单元格里放可显示的文本,点击跳转由 关系决定 + sheet.append(f'' + f'{_xml_escape(value)}') + elif isinstance(value, (int, float)) and not isinstance(value, bool): + sheet.append(f'{value}') + else: + sheet.append(f'' + f'{_xml_escape(value)}') + sheet.append("") + sheet.append("") + + # 超链接关系 + links = [(c_i, r_i, row[c_i]) + for r_i, row in enumerate(rows, start=2) + for c_i in hyperlink_cols + if isinstance(row[c_i], str) and row[c_i].startswith("http")] + if links: + sheet.append("") + for rel_i, (c_i, r_i, url) in enumerate(links, start=1): + col = _col_letter(c_i + 1) + rid = f"rId{rel_i}" + sheet.append(f'') + sheet.append("") + sheet.append("") + parts.append("".join(sheet)) + + # 超链接的关系文件(targetMode=External 不可省略) + rel_items = "".join( + f'' + for i, (_, _, url) in enumerate(links, start=1)) + parts.append('' + '' + f'{rel_items}') + + # 打包 + tmp_path = path + ".tmp" + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", parts[0]) + z.writestr("_rels/.rels", parts[1]) + z.writestr("xl/workbook.xml", parts[2]) + z.writestr("xl/_rels/workbook.xml.rels", parts[3]) + z.writestr("xl/styles.xml", parts[4]) + z.writestr("xl/worksheets/sheet1.xml", parts[5]) + if links: + z.writestr("xl/worksheets/_rels/sheet1.xml.rels", parts[6]) + os.replace(tmp_path, path) + return n_rows + + +# ─── 分享链接表格数据整理 ──────────────────────────────────────────────────── +def split_name_and_path(file_item): + """把一个文件条目拆成「文件名称」与「路径」。 + + 文件名称:去掉路径前缀,再去掉扩展名 + "Cinema 4D/Cinema 4D R20.zip" -> "Cinema 4D R20" + 路径:云盘内的完整相对路径,含文件名与扩展名 + "软件/office/离线安装包/2021/ProPlus2021Retail.img" + """ + filename = str(file_item.get("filename", "") or "") + parent_path = str(file_item.get("path", "") or "").strip("/") + + # 去掉扩展名(最后一段里最后一个点之后非空) + stem = filename + dot = stem.rfind(".") + if dot > 0 and dot < len(stem) - 1: + stem = stem[:dot] + + # 去掉「分类前缀」:若文件名里含有父目录名,剔除该重复前缀部分 + if parent_path: + parent_last = parent_path.split("/")[-1] + if parent_last and stem.startswith(parent_last): + rest = stem[len(parent_last):].lstrip(" -_") + stem = (parent_last + " " + rest).strip() if rest else parent_last + + return stem or filename, (f"{parent_path}/{filename}" if parent_path else filename) + + +def build_share_rows(results): + """把批量分享结果整理成 Excel 三列数据。""" + rows = [] + for r in results: + if not r.get("url"): + continue + file_name, full_path = split_name_and_path(r) + rows.append([file_name, r["url"], full_path]) + return rows + + +def save_share_table(results, output_file, headers=None, widths=None): + """按输出扩展名保存分享结果。xlsx 走零依赖写入器,其它格式回退 JSON。""" + if output_file.endswith(".json"): + with open(output_file, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + return "JSON", len(results) + + if not output_file.endswith((".xlsx", ".xls")): + output_file += ".xlsx" # 未指定扩展名时默认导出 Excel + + rows = build_share_rows(results) + headers = headers or ["文件名称", "链接", "路径"] + widths = widths or [38, 52, 60] + write_xlsx(output_file, "分享链接", headers, rows, widths, hyperlink_cols=(1,)) + return "Excel", len(results) + + +# ─── 配置管理 ─────────────────────────────────────────────────────────────── +class Config: + """管理 API 配置(client_id, client_secret, access_token)""" + + def __init__(self): + self.client_id = "" + self.client_secret = "" + self.access_token = "" + self.expired_at = "" + self.load() + + def load(self): + """从配置文件或环境变量加载""" + # 优先级: 环境变量 > ~/.123yunpan_config.json > skill 目录 config.json + + # 1. 尝试从 skill 目录的 config.json 加载(最低优先级) + skill_config = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "config.json") + if os.path.exists(skill_config): + try: + with open(skill_config, "r", encoding="utf-8") as f: + data = json.load(f) + self.client_id = data.get("client_id", "") + self.client_secret = data.get("client_secret", "") + self.access_token = data.get("access_token", "") + self.expired_at = data.get("expired_at", "") + except (json.JSONDecodeError, IOError): + pass + + # 2. 从 ~/.123yunpan_config.json 加载(覆盖 skill 目录配置) + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + self.client_id = data.get("client_id", "") or self.client_id + self.client_secret = data.get("client_secret", "") or self.client_secret + self.access_token = data.get("access_token", "") or self.access_token + self.expired_at = data.get("expired_at", "") or self.expired_at + except (json.JSONDecodeError, IOError): + pass + + # 3. 环境变量覆盖(最高优先级) + if os.environ.get("TWOPAN_CLIENT_ID"): + self.client_id = os.environ["TWOPAN_CLIENT_ID"] + if os.environ.get("TWOPAN_CLIENT_SECRET"): + self.client_secret = os.environ["TWOPAN_CLIENT_SECRET"] + if os.environ.get("TWOPAN_ACCESS_TOKEN"): + self.access_token = os.environ["TWOPAN_ACCESS_TOKEN"] + + def save(self): + """保存配置到文件""" + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "access_token": self.access_token, + "expired_at": self.expired_at, + } + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def check(self): + """检查配置是否完整""" + missing = [] + if not self.client_id: + missing.append("TWOPAN_CLIENT_ID (client_id)") + if not self.client_secret: + missing.append("TWOPAN_CLIENT_SECRET (client_secret)") + if not self.access_token: + missing.append("TWOPAN_ACCESS_TOKEN (access_token)") + return missing + + def check_warning(self): + """检查配置,如果不完整则打印中文提醒""" + missing = self.check() + if missing: + print_warn("⚠️ 配置不完整!请运行以下命令进行配置:") + print() + print(" 方法一:使用 setup 子命令自动配置:") + print(f" python3 {sys.argv[0]} setup") + print() + print(" 方法二:手动创建配置文件 ~/.123yunpan_config.json:") + print(' {') + print(' "client_id": "你的ClientID",') + print(' "client_secret": "你的ClientSecret",') + print(' "access_token": ""') + print(" }") + print() + print(" 方法三:设置环境变量:") + print(" export TWOPAN_CLIENT_ID='你的ClientID'") + print(" export TWOPAN_CLIENT_SECRET='你的ClientSecret'") + print(" export TWOPAN_ACCESS_TOKEN='你的AccessToken'") + print() + print(f" 缺少的配置项:{', '.join(missing)}") + print() + return False + return True + + +# ─── API 客户端 ────────────────────────────────────────────────────────────── +class ApiClient: + """123云盘 API 客户端 + + 内置三层稳定性保障: + 1. QPS 限流 —— 按官方「开发须知」的限流表控制请求节奏 + 2. 429 / 5xx 指数退避重试 —— 被限流或服务端抖动时自动重试 + 3. Token 自动刷新 —— 收到 401 时刷新后重试一次 + """ + + # 限流状态:{path: 上次请求时间} + _last_call = {} + _lock_guard = None + + @classmethod + def _wait_for_slot(cls, path): + """按端点 QPS 限制等待,返回该端点的每秒配额""" + qps = QPS_RULES.get(path, 1 / MIN_REQUEST_INTERVAL) + min_interval = 1.0 / qps if qps > 0 else MIN_REQUEST_INTERVAL + last = cls._last_call.get(path) + if last is not None: + wait = min_interval - (time.time() - last) + if wait > 0: + time.sleep(wait) + cls._last_call[path] = time.time() + return qps + + def __init__(self, config): + self.config = config + self.base = BASE_URL + self._refreshing = False # 防止递归刷新 + + def _refresh_token(self): + """自动刷新 access_token""" + if not self.config.client_id or not self.config.client_secret: + return False + try: + url = self.base + "/api/v1/access_token" + body = json.dumps({ + "clientID": self.config.client_id, + "clientSecret": self.config.client_secret, + }).encode("utf-8") + req = Request(url, data=body, headers={ + "Content-Type": "application/json", + "Platform": "open_platform", + }, method="POST") + resp = urlopen(req, timeout=30) + result = json.loads(resp.read().decode("utf-8")) + if result.get("code") == 0 and "data" in result: + self.config.access_token = result["data"].get("accessToken", "") + self.config.expired_at = result["data"].get("expiredAt", "") + self.config.save() + print_warn("Token 已自动刷新") + return True + except Exception: + pass + return False + + # ── 单次 HTTP 尝试 ────────────────────────────────────────────────────── + def _send(self, url, method, data, req_headers, raw, timeout=60): + """发起一次请求,返回 (结果 dict, http_code)。成功时 http_code=200。""" + req = Request(url, data=data, headers=req_headers, method=method) + try: + resp = urlopen(req, timeout=timeout) + if raw: + return resp.read(), 200 + resp_data = resp.read().decode("utf-8") + if resp_data: + return json.loads(resp_data), 200 + return {}, 200 + except HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(body) + except json.JSONDecodeError: + parsed = {"error": body} + # 把 HTTP 状态码塞进结果,方便上层判断重试 + if isinstance(parsed, dict): + parsed["http_code"] = e.code + return parsed, e.code + except URLError as e: + return {"error": str(e.reason), "http_code": 0}, 0 + except Exception as e: # 网络层之外的意外(超时、JSON 解析等) + return {"error": str(e), "http_code": 0}, 0 + + def _request(self, method, path, body=None, params=None, headers=None, raw=False, base_url=None): + """发送 HTTP 请求,带限流与自动重试。base_url 可覆盖默认域名(如上传域名)。""" + url = (base_url or self.base) + path + if params: + url += "?" + urlencode(params, doseq=True) + + req_headers = {"Platform": "open_platform"} + if self.config.access_token: + req_headers["Authorization"] = f"Bearer {self.config.access_token}" + if headers: + req_headers.update(headers) + + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + req_headers["Content-Type"] = "application/json" + + ApiClient._wait_for_slot(path) + + max_attempts = 4 # 含首次,共 3 次退避重试 + for attempt in range(1, max_attempts + 1): + resp, http_code = self._send(url, method, data, req_headers, raw) + # 网络层完全失败(无响应 body)—— 属于可重试错误 + if http_code == 0: + if attempt < max_attempts: + time.sleep(2 ** attempt) + continue + return resp + + # 401:Token 失效 → 刷新后重试(只刷新一次,避免无限循环) + if http_code == 401 and not self._refreshing and not raw: + self._refreshing = True + try: + if self._refresh_token(): + req_headers["Authorization"] = f"Bearer {self.config.access_token}" + ApiClient._wait_for_slot(path) + resp, http_code = self._send(url, method, data, req_headers, raw) + if http_code != 0: + return resp + finally: + self._refreshing = False + if http_code != 0: + continue # 刷新后仍失败,走下面的退避/返回逻辑 + + # 429 / 5xx:限流或服务端抖动 → 指数退避后重试 + if http_code == 429 or http_code >= 500: + if attempt < max_attempts: + delay = 2 ** attempt + (0.5 if http_code == 429 else 0) + if not JSON_OUTPUT: + print(_colorize( + f" ⟳ HTTP {http_code},{delay:.1f}s 后重试 ({attempt}/{max_attempts - 1})", + "33"), file=sys.stderr) + time.sleep(delay) + continue + + return resp + + return resp + + def _get(self, path, params=None, headers=None, base_url=None): + return self._request("GET", path, params=params, headers=headers, base_url=base_url) + + def _post(self, path, body=None, params=None, headers=None, base_url=None): + return self._request("POST", path, body=body, params=params, headers=headers, base_url=base_url) + + def _put(self, path, body=None, params=None, headers=None, base_url=None): + return self._request("PUT", path, body=body, params=params, headers=headers, base_url=base_url) + + def _delete(self, path, body=None, params=None, headers=None, base_url=None): + return self._request("DELETE", path, body=body, params=params, headers=headers, base_url=base_url) + + def get_user_id_from_token(self): + """从 JWT token 中解析 user_id,无需额外 API 调用""" + token = self.config.access_token + if not token: + return 0 + try: + parts = token.split(".") + if len(parts) >= 2: + payload = parts[1] + # 补齐 base64 padding + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + data = json.loads(base64.b64decode(payload)) + return data.get("id", 0) + except Exception: + pass + return 0 + + @staticmethod + def _extract(resp): + """从 API 响应中提取数据。123云盘返回 {code, message, data} 或 {result}""" + if resp.get("code") == 0 and "data" in resp: + return resp["data"] + if "result" in resp: + return resp["result"] + return resp + + def _upload_file(self, url, file_path, headers=None, content_type=None): + """上传文件到指定 URL""" + if not content_type: + content_type, _ = mimetypes.guess_type(file_path) + if not content_type: + content_type = "application/octet-stream" + + with open(file_path, "rb") as f: + file_data = f.read() + + req_headers = headers or {} + req_headers["Content-Type"] = content_type + req_headers["Content-Length"] = str(len(file_data)) + + req = Request(url, data=file_data, headers=req_headers, method="PUT") + try: + resp = urlopen(req, timeout=300) + resp_data = resp.read().decode("utf-8") + if resp_data: + return json.loads(resp_data) + return {} + except HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_body) + except json.JSONDecodeError: + return {"error": resp_body, "http_code": e.code} + + def _upload_bytes(self, url, data, headers=None, content_type="application/octet-stream"): + """上传字节数据到指定 URL(用于分片上传)""" + req_headers = headers or {} + req_headers["Content-Type"] = content_type + req_headers["Content-Length"] = str(len(data)) + + req = Request(url, data=data, headers=req_headers, method="PUT") + try: + resp = urlopen(req, timeout=300) + resp_data = resp.read().decode("utf-8") + if resp_data: + return json.loads(resp_data) + return {} + except HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_body) + except json.JSONDecodeError: + return {"error": resp_body, "http_code": e.code} + + def _multipart_upload(self, url, fields, file_path, file_field="file", headers=None): + """multipart/form-data 上传(用于 single/create 等端点)""" + boundary = f"----HermesBoundary{int(time.time()*1000)}" + body = b"" + for name, value in fields.items(): + body += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode() + filename = os.path.basename(file_path) + with open(file_path, "rb") as f: + file_data = f.read() + body += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n".encode() + body += file_data + body += f"\r\n--{boundary}--\r\n".encode() + + req_headers = headers or {} + req_headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + if self.config.access_token: + req_headers["Authorization"] = f"Bearer {self.config.access_token}" + req_headers["Platform"] = "open_platform" + + req = Request(url, data=body, headers=req_headers, method="POST") + try: + resp = urlopen(req, timeout=600) + resp_data = resp.read().decode("utf-8") + if resp_data: + return json.loads(resp_data) + return {} + except HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_body) + except json.JSONDecodeError: + return {"error": resp_body, "http_code": e.code} + + def _download_file(self, url, save_path, file_size=None): + """下载文件并显示进度""" + req = Request(url, method="GET") + req.add_header("User-Agent", "123yunpan-cli/1.0") + resp = urlopen(req, timeout=300) + + content_length = int(resp.headers.get("Content-Length", 0)) + if file_size is None: + file_size = content_length + + os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True) + downloaded = 0 + chunk_size = 65536 + + with open(save_path, "wb") as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if file_size > 0: + progress_bar(downloaded, file_size, prefix="下载进度", + suffix=f"{human_size(downloaded)}/{human_size(file_size)}") + + print_success(f"文件已保存到: {save_path}") + + +# ─── 子命令处理函数 ────────────────────────────────────────────────────────── +def cmd_setup(client, args): + """设置配置并获取 access_token""" + if args.client_id: + client.config.client_id = args.client_id + if args.client_secret: + client.config.client_secret = args.client_secret + + # 未提供凭证时,交互式提示输入 + if not client.config.client_id: + print("⚠️ 未检测到 client_id") + print(" 获取方式:访问 https://www.123pan.com/member 购买开发者权益包") + print(" 购买后通过站内信获取 client_id 和 client_secret") + print() + cid = input("请输入 client_id: ").strip() + if not cid: + print_error("client_id 不能为空,退出。") + return + client.config.client_id = cid + + if not client.config.client_secret: + print("⚠️ 未检测到 client_secret") + csec = input("请输入 client_secret: ").strip() + if not csec: + print_error("client_secret 不能为空,退出。") + return + client.config.client_secret = csec + + print("正在获取 access_token...") + resp = client._post("/api/v1/access_token", body={ + "clientID": client.config.client_id, + "clientSecret": client.config.client_secret, + }) + + # 123云盘 API 返回 {code: 0, message: "ok", data: {accessToken, expiredAt}} + if resp.get("code") == 0 and "data" in resp: + result = resp["data"] + client.config.access_token = result.get("accessToken", "") + client.config.expired_at = result.get("expiredAt", "") + client.config.save() + print_success("配置已保存!") + print(f" Access Token: {client.config.access_token[:30]}...") + print(f" 过期时间: {client.config.expired_at}") + elif "result" in resp: + result = resp["result"] + client.config.access_token = result.get("accessToken", "") + client.config.expired_at = result.get("expiredAt", "") + client.config.save() + print_success("配置已保存!") + print_json(resp) + else: + print_error("获取 access_token 失败") + print_json(resp) + + +# ─── 文件管理 ──────────────────────────────────────────────────────────────── +def cmd_file_list(client, args): + """列出文件""" + params = {"parentFileId": args.parent_id, "limit": args.limit} + if args.last_file_id: + params["lastFileId"] = args.last_file_id + if args.search: + params["searchData"] = args.search + params["searchMode"] = args.search_mode + + # --trashed 需要全量拉取才能过滤 + fetch_all = args.all_pages or args.trashed or args.show_trashed + + if fetch_all: + # 自动分页获取所有文件 + all_files = [] + last_id = args.last_file_id or "" + while True: + if last_id: + params["lastFileId"] = last_id + resp = client._get("/api/v2/file/list", params=params) + data = client._extract(resp) + if data and "fileList" in data: + files = data["fileList"] + all_files.extend(files) + last_id = data.get("lastFileId", "") + if len(files) < args.limit or not last_id: + break + else: + print_json(resp) + return + + # 过滤回收站文件 + if args.trashed: + all_files = [f for f in all_files if f.get("trashed")] + elif not args.show_trashed: + all_files = [f for f in all_files if not f.get("trashed")] + + # 显示结果 + print(f"\033[36m📁 共 {len(all_files)} 个文件\033[0m") + for f in all_files: + ftype = "📁" if f.get("type") == 1 else "📄" + trash_mark = " 🗑️" if f.get("trashed") else "" + size = human_size(f.get("size", 0)) + print(f" {ftype} [{f.get('fileId', '')}] {f.get('filename', '')} ({size}){trash_mark}") + return + + resp = client._get("/api/v2/file/list", params=params) + print_json(resp) + + +def cmd_file_detail(client, args): + """获取文件详情""" + resp = client._get("/api/v1/file/detail", params={"fileID": args.file_id}) + print_json(resp) + + +def cmd_file_search(client, args): + """跨文件夹递归搜索文件""" + keyword = args.keyword.lower() + root_id = args.parent_id + max_depth = args.max_depth + + print(f"\033[36m🔍 正在搜索 '{args.keyword}' (最大深度: {max_depth})...\033[0m") + + # 使用缓存的 BFS 获取所有文件(传入 max_depth 限制遍历深度) + use_cache = not args.no_cache + all_files, _ = bfs_list_files(client, root_id, use_cache=use_cache, show_progress=True, max_depth=max_depth) + + # 在结果中过滤 + results = [f for f in all_files if keyword in f.get("filename", "").lower()] + + if results: + print(f"\n\033[36m🎯 找到 {len(results)} 个匹配文件:\033[0m") + for r in results: + full_path = r["path"].split("/")[-1] + "/" + r["filename"] if r["path"] else r["filename"] + print(f" 📄 [{r['fileId']}] {full_path} ({human_size(r['size'])})") + else: + print_warn(f"未找到包含 '{args.keyword}' 的文件") + + +def cmd_file_details_multi(client, args): + """批量获取文件详情""" + body = {"fileIds": args.file_ids} + resp = client._post("/api/v1/file/infos", body=body) + print_json(resp) + + +def cmd_mkdir(client, args): + """创建文件夹""" + body = {"name": args.name, "parentID": args.parent_id} + resp = client._post("/upload/v1/file/mkdir", body=body) + print_json(resp) + data = client._extract(resp) + if data and "dirID" in data: + print_success(f"文件夹 '{args.name}' 创建成功,ID: {data.get('dirID', '')}") + + +def cmd_rename(client, args): + """重命名文件""" + body = {"fileId": args.file_id, "name": args.new_name} + resp = client._put("/api/v1/file/name", body=body) + print_json(resp) + + +def cmd_rename_batch(client, args): + """批量重命名文件""" + body = {"fileIdList": args.file_ids, "nameList": args.names} + resp = client._post("/api/v1/file/rename", body=body) + print_json(resp) + + +def cmd_move(client, args): + """移动文件""" + body = {"fileIDs": args.file_ids, "toParentFileID": args.target_parent_id} + resp = client._post("/api/v1/file/move", body=body) + print_json(resp) + + +def cmd_copy(client, args): + """复制文件""" + body = {"fileID": args.file_id, "targetParentFileID": args.target_parent_id} + resp = client._post("/api/v1/file/copy", body=body) + print_json(resp) + + +def cmd_copy_batch(client, args): + """批量复制文件""" + body = {"fileIDs": args.file_ids, "targetParentFileID": args.target_parent_id} + resp = client._post("/api/v1/file/async/copy", body=body) + print_json(resp) + + +def cmd_copy_progress(client, args): + """查询批量复制进度""" + resp = client._get("/api/v1/file/async/copy/process", params={"taskID": args.task_id}) + print_json(resp) + + +def cmd_trash(client, args): + """移入回收站""" + if args.dry_run: + # 预览模式:获取文件信息但不执行删除 + print(f"\033[36m🔍 预览模式:以下 {len(args.file_ids)} 个文件将被移入回收站\033[0m") + for fid in args.file_ids: + resp = client._get("/api/v1/file/detail", params={"fileID": fid}) + data = client._extract(resp) + if data: + name = data.get("filename", "未知") + size = human_size(data.get("size", 0)) + print(f" 📄 [{fid}] {name} ({size})") + else: + print(f" ❓ [{fid}] (无法获取信息)") + print(f"\n\033[33m⚠️ 这是预览模式,未执行任何操作。去掉 --dry-run 才会真正删除。\033[0m") + return + body = {"fileIDs": args.file_ids} + resp = client._post("/api/v1/file/trash", body=body) + print_json(resp) + + +def cmd_recover(client, args): + """从回收站恢复文件""" + body = {"fileIDs": args.file_ids} + resp = client._post("/api/v1/file/recover", body=body) + print_json(resp) + + +def cmd_recover_by_path(client, args): + """按路径恢复文件""" + body = { + "fileID": args.file_id, + "targetParentFileID": args.target_parent_id, + "filename": args.filename, + } + resp = client._post("/api/v1/file/recover/by_path", body=body) + print_json(resp) + + +def get_download_info(client, file_id): + """获取下载地址。 + + download_info 的参数名在文档里写作 fileId,而 file/detail 写作 fileID, + 这里两种拼写都试一遍,保证不同版本的实现都能拿到结果。 + """ + for key in ("fileId", "fileID"): + resp = client._get("/api/v1/file/download_info", params={key: file_id}) + data = client._extract(resp) + if isinstance(data, dict) and "downloadUrl" in data: + return data + return None + + +def cmd_download(client, args): + """下载文件""" + data = get_download_info(client, args.file_id) + if data is None: + print_error("获取下载地址失败:文件不存在(5066)或自用下载流量不足(5113)") + return + url = data["downloadUrl"] + filename = args.output or data.get("fileName") or data.get("filename") or f"download_{args.file_id}" + print(f"开始下载: {filename}") + try: + client._download_file(url, filename) + except (HTTPError, URLError) as e: + reason = f"HTTP {e.code}" if isinstance(e, HTTPError) else str(e.reason) + print_error(f"下载失败: {reason}") + if isinstance(e, HTTPError) and e.code == 403: + print_warn("可能是自用下载流量不足(5113),升级 VIP 可获得无限流量") + + +# ─── 上传 (V2) ───────────────────────────────────────────────────────────── +def cmd_upload(client, args): + """上传文件(自动选择单次上传或分片上传)""" + file_path = args.file + if not os.path.exists(file_path): + print_error(f"文件不存在: {file_path}") + return + + filename = args.filename or os.path.basename(file_path) + file_size = os.path.getsize(file_path) + file_md5 = md5_file(file_path) + + parent_id = args.parent_id or "0" + duplicate = args.duplicate # 0=跳过, 1=重命名, 2=覆盖 + + # 上传前空间预检:专业空间/标准空间超额时,开放平台上传入口会被直接阻断 + try: + _resp, _err = _get_user_info(client) + if not _err: + warn_if_professional_space_low(format_space_report( + client._extract(_resp) or {})) + except Exception: + pass + + # 获取上传域名 + print("获取上传域名...") + domain_resp = client._get("/upload/v2/file/domain") + domain_data = client._extract(domain_resp) + if not domain_data: + print_error("获取上传域名失败") + print_json(domain_resp) + return + + # API 返回列表 ["https://..."] 或对象 {"host": "https://..."} + if isinstance(domain_data, list) and domain_data: + upload_domain = domain_data[0] + elif isinstance(domain_data, dict): + upload_domain = domain_data.get("host", "") + else: + upload_domain = "" + + if not upload_domain: + print_error("上传域名为空") + print_json(domain_resp) + return + + print(f"上传域名: {upload_domain}") + + # 判断使用单次上传还是分片上传 + SINGLE_FILE_MAX = 100 * 1024 * 1024 # 100MB + + if file_size <= SINGLE_FILE_MAX: + # 单次上传(multipart/form-data) + print(f"使用单次上传模式 ({human_size(file_size)})") + fields = { + "parentFileID": str(int(parent_id)), + "filename": filename, + "etag": file_md5, + "size": str(file_size), + } + # duplicate: 1=重命名, 2=覆盖(不传=覆盖,默认行为) + if duplicate is not None: + fields["duplicate"] = str(duplicate) + resp = client._multipart_upload( + f"{upload_domain}/upload/v2/file/single/create", + fields, file_path + ) + if resp.get("code") != 0: + print_error(f"上传失败: {resp.get('message', '未知错误')}") + print_json(resp) + return + result = client._extract(resp) + if not result: + print_error("创建上传任务失败") + print_json(resp) + return + + # 检查秒传(SHA1 复用) + if result.get("reuse"): + file_id = result.get("fileID", "") + print_success(f"⚡ 秒传成功!文件已存在,跳过上传 (fileID: {file_id})") + print_json(result) + return + + file_id = result.get("fileID", "") + print_success(f"✅ 文件上传完成!(fileID: {file_id})") + print_json(result) + else: + # 分片上传 + print(f"使用分片上传模式 ({human_size(file_size)})") + body = { + "parentFileID": int(parent_id), + "filename": filename, + "etag": file_md5, + "size": file_size, + } + # duplicate: 1=重命名, 2=覆盖(不传=覆盖,默认行为) + if duplicate is not None: + body["duplicate"] = duplicate + # create 用主 API 域名 + resp = client._post("/upload/v2/file/create", body=body) + if resp.get("code") != 0: + print_error(f"创建分片上传任务失败: {resp.get('message', '未知错误')}") + print_json(resp) + return + result = client._extract(resp) + if not result: + print_error("创建分片上传任务失败") + print_json(resp) + return + + # 检查秒传(SHA1 复用) + if result.get("reuse"): + file_id = result.get("fileID", "") + print_success(f"⚡ 秒传成功!文件已存在,跳过上传 (fileID: {file_id})") + print_json(result) + return + + slice_size = result.get("sliceSize", SLICE_SIZE) + preupload_id = result.get("preuploadID", "") + + total_slices = math.ceil(file_size / slice_size) + print(f"分片数: {total_slices}, 分片大小: {human_size(slice_size)}") + + with open(file_path, "rb") as f: + for i in range(1, total_slices + 1): + chunk = f.read(slice_size) + chunk_md5 = hashlib.md5(chunk).hexdigest() + + slice_body = { + "preuploadID": preupload_id, + "sliceNo": i, + "partMd5": chunk_md5, + } + # 需要先获取上传URL + slice_url_resp = client._post(f"/upload/v2/file/slice", + body=slice_body, base_url=upload_domain) + slice_data = client._extract(slice_url_resp) + if not slice_data: + print_error(f"分片 {i}/{total_slices} 获取上传URL失败") + print_json(slice_url_resp) + return + + upload_url = slice_data.get("uploadUrl", "") + if not upload_url: + print_error(f"分片 {i}/{total_slices} 上传URL为空") + print_json(slice_url_resp) + return + + # 上传分片(使用 chunk 字节数据,而非整个文件) + print(f"上传分片 {i}/{total_slices}...", end="", flush=True) + resp_upload = client._upload_bytes(upload_url, chunk, + content_type="application/octet-stream") + print(f" ✓") + + # 完成上传(带轮询校验) + print("等待服务器校验分片...") + max_retries = max(60, min(300, total_slices * 2)) # 根据分片数自适应 + for attempt in range(max_retries): + complete_resp = client._post("/upload/v2/file/upload_complete", + body={"preuploadID": preupload_id}, base_url=upload_domain) + complete_data = client._extract(complete_resp) + if complete_data and complete_data.get("completed"): + print(f" 校验完成(第 {attempt + 1} 次轮询)") + print_json(complete_resp) + print_success("文件上传完成!") + return + # 检查是否有需要等待的提示 + msg = complete_resp.get("message", "") + if any(kw in msg for kw in ["校验", "verify", "check", "间隔"]): + if attempt % 10 == 0: + print(f" 服务器校验中... 已等待 {attempt}秒") + elif attempt >= 5 and complete_resp.get("code") != 0: + # 非等待类错误,提前退出 + print_error(f"上传完成确认失败: {msg}") + print_json(complete_resp) + return + time.sleep(1) + print_warn(f"轮询超时({max_retries}秒),文件可能仍在校验中") + print_json(complete_resp) + + +# ─── 分享管理 ──────────────────────────────────────────────────────────────── +def cmd_share_create(client, args): + """创建分享""" + body = { + "shareName": args.name, + "shareExpire": args.expire, + "fileIDList": ",".join(str(x) for x in args.file_ids), + } + if args.pwd: + body["sharePwd"] = args.pwd + if args.traffic_switch is not None: + body["trafficSwitch"] = args.traffic_switch + if args.traffic_limit_switch is not None: + body["trafficLimitSwitch"] = args.traffic_limit_switch + if args.traffic_limit is not None: + body["trafficLimit"] = args.traffic_limit + + # 文件过多时主动分段,避免超过 API 的 100 个上限 + file_ids = args.file_ids + if len(file_ids) > SHARE_MAX_FILE_IDS: + print_warn(f"文件数 {len(file_ids)} 超过单次上限 {SHARE_MAX_FILE_IDS}," + f"只取前 {SHARE_MAX_FILE_IDS} 个") + + body["fileIDList"] = ",".join(str(x) for x in file_ids[:SHARE_MAX_FILE_IDS]) + + resp = client._post("/api/v1/share/create", body=body) + data = resp.get("data") or {} + share_key = data.get("shareKey", "") + if share_key: + resp["_shareUrl"] = _share_url(client, share_key, getattr(args, "url_format", "official")) + print_json(resp) + if not JSON_OUTPUT and share_key: + print_success(f"分享链接: {resp['_shareUrl']}") + + +def _share_url(client, share_key, url_format): + """按指定格式拼接分享链接。""" + if url_format in ("uid", "official"): + user_id = client.get_user_id_from_token() + if user_id: + return f"https://{user_id}.share.123pan.cn/123pan/{share_key}" + # 拿不到 uid 时退回到官方默认格式 + return f"https://www.123pan.com/s/{share_key}" + + +def _list_direct_files(client, root_id): + """只取文件夹直接子项中的文件(不递归),带全量分页。""" + all_files = [] + last_id = 0 + while True: + params = {"parentFileId": root_id, "limit": MAX_LIMIT} + if last_id: + params["lastFileId"] = last_id + resp = client._get("/api/v2/file/list", params=params) + data = client._extract(resp) + if not isinstance(data, dict): + break + for item in data.get("fileList", []): + if item.get("trashed") or item.get("type") == 1: + continue + if _is_rejected(item): + continue + all_files.append({ + "fileId": item["fileId"], + "filename": item.get("filename", ""), + "path": "", + "size": item.get("size", 0), + "status": item.get("status", 0), + }) + nxt = data.get("lastFileId", -1) + if not nxt or nxt == -1 or nxt == last_id: + break + last_id = nxt + return all_files + + +def cmd_share_create_batch(client, args): + """批量创建分享:递归遍历文件夹,为所有文件创建分享链接""" + root_id = args.parent_id + expire = args.expire + output_file = args.output + + if args.no_recursive: + all_files = _list_direct_files(client, root_id) + print(_colorize(f"📁 直接子项: {len(all_files)} 个文件", "36")) + else: + all_files, _ = bfs_list_files(client, root_id, use_cache=not args.no_cache) + + if not all_files: + print_warn("未找到任何文件(可能全部是文件夹,或已被审核驳回)") + return + + # 去掉重复 fileId + seen, unique_files = set(), [] + for f in all_files: + if f["fileId"] in seen: + continue + seen.add(f["fileId"]) + unique_files.append(f) + all_files = unique_files + + total = len(all_files) + if not JSON_OUTPUT: + expire_desc = "永久" if expire == 0 else f"{expire}天" + print(f"\n{_colorize(f'🔗 开始创建分享链接 (有效期: {expire_desc}, 共 {total} 个文件)', '36')}\n") + + # ── 断点续跑 ──────────────────────────────────────────────────────────── + ckpt_path = None + results = [] + todo = all_files + if args.resume: + ckpt_path = os.path.join(CACHE_DIR, f"share_batch_{root_id}.json") + os.makedirs(CACHE_DIR, exist_ok=True) + if os.path.exists(ckpt_path): + try: + with open(ckpt_path, "r", encoding="utf-8") as f: + ckpt = json.load(f) + done_ids = {r["fileId"] for r in ckpt.get("results", [])} + results = ckpt["results"] + todo = [f for f in all_files if f["fileId"] not in done_ids] + print(_colorize(f"♻️ 续跑模式:已完成 {len(results)} 个,剩余 {len(todo)} 个", "36")) + except (json.JSONDecodeError, KeyError, IOError): + results = [] + + def _save_checkpoint(): + if ckpt_path: + tmp = ckpt_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"root_id": root_id, "expire": expire, "results": results}, + f, ensure_ascii=False) + os.replace(tmp, ckpt_path) + + def _truncate_name(name): + # 分享名称过长会被 API 拒绝,主动截断到安全长度 + if len(name) > SHARE_NAME_MAX_LEN: + return name[:SHARE_NAME_MAX_LEN - 3] + "..." + return name + + url_format = getattr(args, "url_format", "official") + start_time = time.time() + fail_count = 0 + fails = [] + + for i, f in enumerate(todo): + display_name = f"{f['path']}/{f['filename']}" if f.get("path") else f["filename"] + body = { + "shareName": _truncate_name(display_name), + "shareExpire": expire, + "fileIDList": str(f["fileId"]), + } + resp = client._post("/api/v1/share/create", body=body) + data = client._extract(resp) + if isinstance(data, dict) and data.get("shareKey"): + share_key = data["shareKey"] + result = { + "name": display_name, + "filename": f.get("filename", ""), + "path": f.get("path", ""), + "url": _share_url(client, share_key, url_format), + "fileId": f["fileId"], + "shareKey": share_key, + } + results.append(result) + else: + code = resp.get("code", 0) if isinstance(resp, dict) else 0 + msg = error_hint(code, resp.get("message", "未知错误")) if isinstance(resp, dict) else "未知错误" + fail_count += 1 + fails.append({"name": display_name, "fileId": f["fileId"], "error": msg}) + result = {"name": display_name, "filename": f.get("filename", ""), + "path": f.get("path", ""), "url": "", "fileId": f["fileId"], + "shareKey": "", "error": msg} + + if not JSON_OUTPUT: + pct = (i + 1) / total * 100 + bar_len = 20 + filled = int(bar_len * (i + 1) / total) + bar = "█" * filled + "░" * (bar_len - filled) + eta_str = "" + elapsed = time.time() - start_time + if i > 0 and elapsed > 0: + remaining = elapsed / (i + 1) * (total - i - 1) + if remaining >= 3600: + eta_str = f" ETA:{int(remaining // 3600)}h{int((remaining % 3600) // 60)}m" + elif remaining >= 60: + eta_str = f" ETA:{int(remaining // 60)}m{int(remaining % 60)}s" + else: + eta_str = f" ETA:{int(remaining)}s" + mark = "✓" if result["url"] else "✗" + mark = _colorize(mark, "32") if result["url"] else _colorize(mark, "31") + short = display_name if len(display_name) <= 35 else "..." + display_name[-32:] + print(f"\r {bar} {pct:5.1f}% [{i+1}/{total}] {short} {mark}{eta_str}", + end="", flush=True) + + _save_checkpoint() + if args.no_recursive: + time.sleep(0.35) + elif (i + 1) % 5 == 0: + time.sleep(0.5) + + if not JSON_OUTPUT: + print() + + success = len(results) + if not JSON_OUTPUT: + print(f"\n{_colorize('=' * 50, '36')}") + print(f"{_colorize(f'✅ 完成!成功 {success} 个,失败 {fail_count} 个', '32')}") + + if JSON_OUTPUT: + print_json({"total": total, "success": success, "fail": fail_count, + "results": results, "fails": fails}) + return + + if fails: + print(_colorize("❌ 失败明细:", "31")) + for f in fails[:20]: + print(f" [{f['fileId']}] {f['name']} — {f['error']}") + if len(fails) > 20: + print(f" ... 另有 {len(fails) - 20} 条失败") + + # ── 输出:链接数超过阈值时自动导出 Excel ──────────────────────────────── + threshold = getattr(args, "excel_threshold", SHARE_EXCEL_THRESHOLD) + link_count = sum(1 for r in results if r.get("url")) + + if args.no_excel and not output_file: + for r in results: + if r["url"]: + print(f" {r['name']}: {r['url']}") + return + + if output_file: + fmt, n = save_share_table(results, output_file) + print(f"\n📊 {fmt} 已保存: {output_file} ({n} 条)") + return + + if link_count > threshold: + auto_name = os.path.join(os.getcwd(), f"123pan_shares_{root_id}.xlsx") + fmt, n = save_share_table(results, auto_name) + print(f"\n📊 链接数 {link_count} 超过阈值 {threshold},已自动生成 {fmt}:{auto_name} ({n} 条)") + print(f" 列包含:文件名称(不含分类前缀) / 链接 / 路径") + else: + print() + for r in results: + if r["url"]: + print(f" {r['name']}: {r['url']}") + + +def cmd_share_list(client, args): + """列出分享""" + if args.all_pages: + all_shares = [] + last_id = args.last_share_id or "" + while True: + params = {"limit": args.limit} + if last_id: + params["lastShareId"] = last_id + resp = client._get("/api/v1/share/list", params=params) + data = client._extract(resp) + if data and "shareList" in data: + shares = data["shareList"] + all_shares.extend(shares) + last_id = str(data.get("lastShareId", "")) + if len(shares) < args.limit or not last_id or last_id == "-1": + break + else: + print_json(resp) + return + print(f"\033[36m🔗 共 {len(all_shares)} 个分享\033[0m") + for s in all_shares: + name = s.get("shareName", "未命名") + sid = s.get("shareId", "") + print(f" 🔗 [{sid}] {name}") + return + + params = {"limit": args.limit} + if args.last_share_id: + params["lastShareId"] = args.last_share_id + resp = client._get("/api/v1/share/list", params=params) + print_json(resp) + + +def cmd_share_update(client, args): + """更新分享设置""" + body = {"shareIdList": args.share_ids} + if args.traffic_switch is not None: + body["trafficSwitch"] = args.traffic_switch + if args.traffic_limit_switch is not None: + body["trafficLimitSwitch"] = args.traffic_limit_switch + if args.traffic_limit is not None: + body["trafficLimit"] = args.traffic_limit + resp = client._put("/api/v1/share/list/info", body=body) + print_json(resp) + + +def cmd_share_pay_create(client, args): + """创建付费分享""" + body = { + "shareName": args.name, + "fileIDList": ",".join(str(x) for x in args.file_ids), + "payAmount": args.amount, + } + if args.is_reward: + body["isReward"] = 1 + if args.desc: + body["resourceDesc"] = args.desc + resp = client._post("/api/v1/share/content-payment/create", body=body) + print_json(resp) + + +def cmd_share_pay_list(client, args): + """列出付费分享""" + if args.all_pages: + all_shares = [] + last_id = args.last_share_id or "" + while True: + params = {"limit": args.limit} + if last_id: + params["lastShareId"] = last_id + resp = client._get("/api/v1/share/payment/list", params=params) + data = client._extract(resp) + if data and "shareList" in data: + shares = data["shareList"] + all_shares.extend(shares) + last_id = str(data.get("lastShareId", "")) + if len(shares) < args.limit or not last_id or last_id == "-1": + break + else: + print_json(resp) + return + print(f"\033[36m💰 共 {len(all_shares)} 个付费分享\033[0m") + for s in all_shares: + name = s.get("shareName", "未命名") + sid = s.get("shareId", "") + amount = s.get("payAmount", "?") + print(f" 💰 [{sid}] {name} ({amount}元)") + return + + params = {"limit": args.limit} + if args.last_share_id: + params["lastShareId"] = args.last_share_id + resp = client._get("/api/v1/share/payment/list", params=params) + print_json(resp) + + +def cmd_share_pay_update(client, args): + """更新付费分享设置""" + body = {"shareIdList": args.share_ids} + if args.traffic_switch is not None: + body["trafficSwitch"] = args.traffic_switch + if args.traffic_limit_switch is not None: + body["trafficLimitSwitch"] = args.traffic_limit_switch + if args.traffic_limit is not None: + body["trafficLimit"] = args.traffic_limit + resp = client._put("/api/v1/share/list/payment/info", body=body) + print_json(resp) + + +def cmd_share_stats(client, args): + """分享链接统计汇总""" + all_shares = [] + last_id = "" + while True: + params = {"limit": 100} + if last_id: + params["lastShareId"] = last_id + resp = client._get("/api/v1/share/list", params=params) + data = client._extract(resp) + if not data or "shareList" not in data: + break + shares = data["shareList"] + all_shares.extend(shares) + last_id = str(data.get("lastShareId", "")) + if len(shares) < 100 or not last_id or last_id == "-1": + break + time.sleep(0.3) + + if not all_shares: + print_warn("没有分享链接") + return + + total_preview = sum(s.get("previewCount", 0) for s in all_shares) + total_download = sum(s.get("downloadCount", 0) for s in all_shares) + total_save = sum(s.get("saveCount", 0) for s in all_shares) + total_traffic = sum(s.get("bytesCharge", 0) for s in all_shares) + + print(f"\n\033[36m{'='*50}\033[0m") + print(f"\033[36m📊 分享链接统计\033[0m") + print(f"\033[36m{'='*50}\033[0m") + print(f" 🔗 分享总数: {len(all_shares)} 个") + print(f" 👁️ 总预览次数: {total_preview:,}") + print(f" ⬇️ 总下载次数: {total_download:,}") + print(f" 💾 总转存次数: {total_save:,}") + print(f" 📦 总流量: {human_size(total_traffic)}") + print(f"\033[36m{'='*50}\033[0m") + + if args.top: + # 按下载量排序,显示 Top N + sorted_shares = sorted(all_shares, key=lambda x: x.get("downloadCount", 0), reverse=True) + print(f"\n\033[36m🏆 下载量 Top {args.top}:\033[0m") + for i, s in enumerate(sorted_shares[:args.top]): + name = s.get("shareName", "未命名") + dl = s.get("downloadCount", 0) + pv = s.get("previewCount", 0) + sv = s.get("saveCount", 0) + print(f" {i+1}. {name}") + print(f" 预览 {pv:,} | 下载 {dl:,} | 转存 {sv:,}") + + +def cmd_share_delete_batch(client, args): + """批量删除分享链接""" + if args.all: + # 删除所有分享 + print("\033[33m⚠️ 正在查找所有分享链接...\033[0m") + all_share_ids = [] + all_share_names = [] + last_id = "" + while True: + params = {"limit": 100} + if last_id: + params["lastShareId"] = last_id + resp = client._get("/api/v1/share/list", params=params) + data = client._extract(resp) + if not data or "shareList" not in data: + break + shares = data["shareList"] + for s in shares: + all_share_ids.append(str(s.get("shareId", ""))) + all_share_names.append(s.get("shareName", "未命名")) + last_id = str(data.get("lastShareId", "")) + if len(shares) < 100 or not last_id or last_id == "-1": + break + time.sleep(0.3) + + if not all_share_ids: + print_warn("没有分享链接可删除") + return + + if args.dry_run: + print(f"\033[36m🔍 预览模式:以下 {len(all_share_ids)} 个分享将被删除\033[0m") + for i, (sid, name) in enumerate(zip(all_share_ids, all_share_names)): + print(f" 🔗 [{sid}] {name}") + print(f"\n\033[33m⚠️ 这是预览模式,未执行任何操作。去掉 --dry-run 才会真正删除。\033[0m") + return + + print(f" 找到 {len(all_share_ids)} 个分享链接") + + if not args.yes: + confirm = input(" 确认删除全部?(y/N): ").strip().lower() + if confirm != "y": + print("已取消") + return + + # 批量删除(每批最多 100 个) + success = 0 + for i in range(0, len(all_share_ids), 100): + batch = all_share_ids[i:i+100] + resp = client._post("/api/v1/share/delete", body={"shareIdList": batch}) + data = client._extract(resp) + if resp.get("code") == 0: + success += len(batch) + time.sleep(0.5) + + print(f"\033[32m✅ 删除完成!成功 {success}/{len(all_share_ids)}\033[0m") + elif args.share_ids: + share_ids = [str(x) for x in args.share_ids] + + if args.dry_run: + print(f"\033[36m🔍 预览模式:以下 {len(share_ids)} 个分享将被删除\033[0m") + for sid in share_ids: + resp = client._get("/api/v1/share/list", params={"limit": 100, "lastShareId": ""}) + print(f" 🔗 [{sid}]") + print(f"\n\033[33m⚠️ 这是预览模式,未执行任何操作。去掉 --dry-run 才会真正删除。\033[0m") + return + + print(f" 删除 {len(share_ids)} 个分享链接...") + + if not args.yes: + confirm = input(" 确认删除?(y/N): ").strip().lower() + if confirm != "y": + print("已取消") + return + + resp = client._post("/api/v1/share/delete", body={"shareIdList": share_ids}) + data = client._extract(resp) + if resp.get("code") == 0: + print_success(f"成功删除 {len(share_ids)} 个分享链接") + else: + msg = resp.get("message", "未知错误") + print_error(f"删除失败: {msg}") + else: + print_warn("请指定 --share-ids 或 --all") + + +# ─── 离线下载 ──────────────────────────────────────────────────────────────── +def cmd_offline_create(client, args): + """创建离线下载任务""" + body = {"url": args.url, "parentFileID": args.parent_id} + if args.filename: + body["fileName"] = args.filename + resp = client._post("/api/v1/offline/download", body=body) + print_json(resp) + + +def cmd_offline_progress(client, args): + """查询离线下载进度""" + resp = client._get("/api/v1/offline/download/process", params={"taskID": args.task_id}) + print_json(resp) + + +# ─── 用户信息 / 空间 ───────────────────────────────────────────────────────── +def _get_user_info(client): + """调用 user/info 并在出错时返回带中文提示的信息""" + resp = client._get("/api/v1/user/info") + if not isinstance(resp, dict) or resp.get("code") not in (0, None): + msg = error_hint(resp.get("code", 0), resp.get("message", "未知错误")) + return None, msg + return resp, None + + +def _parse_size(value): + """user/info 的空间字段既有字节数也有字符串,统一转成 int""" + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def format_space_report(data): + """把 user/info 的空间字段整理成结构化报告(含专业/标准空间拆分)""" + professional_used = _parse_size(data.get("ProfessionalSpaceUsed")) + professional_total = _parse_size(data.get("ProfessionalSpacePermanent")) + standard_used = _parse_size(data.get("StandardSpaceUsed")) + standard_total = _parse_size(data.get("StandardSpacePermanent")) + + total_used = _parse_size(data.get("SpaceUsedTotal")) or ( + professional_used + standard_used) + total_quota = _parse_size(data.get("SpacePermanentTotal")) or ( + professional_total + standard_total) + + return { + "uid": data.get("uid"), + "nickname": data.get("nickname"), + "vip": data.get("vip"), + "vipLabel": ((data.get("vipInfo") or {}).get("vipLabel") + if isinstance(data.get("vipInfo"), dict) else None), + "total_quota": total_quota, + "total_used": total_used, + "space_temp": _parse_size(data.get("spaceTemp")), + "space_temp_expr": data.get("spaceTempExpr", ""), + "professional": {"used": professional_used, "quota": professional_total, + "remaining_days": data.get("ProfessionalSpaceRemainingDays")}, + "standard": {"used": standard_used, "quota": standard_total}, + } + + +def cmd_user_info(client, args): + """获取用户信息""" + resp, err = _get_user_info(client) + if err: + print_error(err) + return + if JSON_OUTPUT: + print_json(resp) + return + + space = format_space_report(client._extract(resp) or {}) + vip = space.get("vipLabel") or ("是" if space.get("vip") else "否") + print(f"\n{_colorize('👤 用户信息', '36')}") + print(f" 用户ID: {space['uid']}") + print(f" 昵称: {space['nickname']}") + print(f" 会员: {vip}") + print(f" 总空间: {human_size(space['total_quota'])}(已用 {human_size(space['total_used'])})") + print_space_details(space) + + +def print_space_details(space, indent=" "): + """打印专业空间 / 标准空间明细,含超额与清理倒计时预警""" + pro, std = space["professional"], space["standard"] + pct_pro = (pro["used"] / pro["quota"]) if pro["quota"] else 0 + pct_std = (std["used"] / std["quota"]) if std["quota"] else 0 + + print(f"{indent}📦 专业空间: {human_size(pro['used'])} / {human_size(pro['quota'])} " + f"({pct_pro * 100:.1f}%)") + if pro["quota"] and pct_pro >= 0.9: + print(f"{indent} 🔴 专业空间即将超额!超额后开放平台上传会被阻断") + if pro["remaining_days"] not in (None, "", 0): + days = pro["remaining_days"] + print(f"{indent} ⏳ 距专业空间超额清理倒计时: {days} 天" + f"{_colorize('(超额后超期未处理会被清理,且不可恢复)', '33')}") + + print(f"{indent}📦 标准空间: {human_size(std['used'])} / {human_size(std['quota'])} " + f"({pct_std * 100:.1f}%)") + if std["quota"] and pct_std >= 0.9: + print(f"{indent} 🔴 标准空间即将超额!超额后新增上传/转存会被阻断") + if space.get("space_temp"): + print(f"{indent}🧊 临时空间: {human_size(space['space_temp'])}" + f"{f"({space['space_temp_expr']} 到期)" if space.get("space_temp_expr") else ""}") + + +def cmd_space(client, args): + """空间占用报告(专业空间 / 标准空间)""" + resp, err = _get_user_info(client) + if err: + print_error(err) + return + data = client._extract(resp) or {} + + if JSON_OUTPUT: + print_json(format_space_report(data)) + return + + space = format_space_report(data) + print(f"\n{_colorize('=' * 56, '36')}") + print(f"{_colorize('💾 空间占用报告', '36')} {space['nickname']}") + print(f"{_colorize('=' * 56, '36')}") + print(f" 总配额: {human_size(space['total_quota'])}") + print(f" 总已用: {human_size(space['total_used'])}" + f"(剩余 {human_size(max(space['total_quota'] - space['total_used'], 0))})") + print_space_details(space) + print(f"{_colorize('=' * 56, '36')}\n") + + +# ─── 空间规则说明(用于上传前预检) ──────────────────────────────────────────── +def warn_if_professional_space_low(space): + """专业空间接近上限时给出预警:超过配额后开放平台上传会被直接阻断""" + pro = space["professional"] + if not pro["quota"]: + return + if pro["used"] >= pro["quota"]: + print_warn("⚠️ 专业空间已满:开放平台上传 / 离线下载 / 转码 等入口会被阻断," + "请先清理专业空间文件") + elif pro["used"] >= pro["quota"] * 0.9: + print_warn(f"⚠️ 专业空间已用 {pro['used'] * 100 // pro['quota']}%,继续上传可能触发超额限制") + + +# ─── 直链管理 ──────────────────────────────────────────────────────────────── +def cmd_direct_enable(client, args): + """启用文件直链""" + resp = client._post("/api/v1/direct-link/enable", body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_direct_disable(client, args): + """禁用文件直链""" + resp = client._post("/api/v1/direct-link/disable", body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_direct_url(client, args): + """获取文件直链URL""" + resp = client._get("/api/v1/direct-link/url", params={"fileID": args.file_id}) + data = client._extract(resp) + if isinstance(data, dict) and "url" in data: + if not JSON_OUTPUT: + print_success(f"直链: {data['url']}") + print_json(resp) + return + print_error(error_hint(resp.get("code", 0) if isinstance(resp, dict) else 0, + "获取直链失败(文件可能不在已开启直链空间的文件夹中)")) + print_json(resp) + + +def cmd_direct_short_url(client, args): + """生成短直链 URL(不暴露文件名,更隐私)""" + user_id = client.get_user_id_from_token() + if not user_id: + # 回退:从 API 获取直链,再从中提取 user_id + resp = client._get("/api/v1/direct-link/url", params={"fileID": args.file_id}) + data = client._extract(resp) + if data and "url" in data: + # URL 格式: https://{uid}.v.123pan.cn/{uid}/{file_id}/{filename} + parts = data["url"].split(".") + if parts: + user_id = parts[0].split("//")[-1] + if not user_id: + print_error("无法获取 user_id,无法生成短直链") + return + short_url = f"https://{user_id}.v.123pan.cn/{user_id}/{args.file_id}" + print_json({"short_url": short_url, "user_id": user_id, "file_id": args.file_id}) + + +def cmd_direct_cache_refresh(client, args): + """刷新直链缓存""" + resp = client._post("/api/v1/direct-link/cache/refresh", body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_direct_ip_list(client, args): + """获取IP白名单""" + resp = client._get("/api/v1/developer/config/forbide-ip/list") + print_json(resp) + + +def cmd_direct_ip_switch(client, args): + """切换IP白名单开关""" + resp = client._post("/api/v1/developer/config/forbide-ip/switch", + body={"switch": args.switch_state}) + print_json(resp) + + +def cmd_direct_ip_update(client, args): + """更新IP白名单""" + resp = client._post("/api/v1/developer/config/forbide-ip/update", + body={"ipList": args.ips}) + print_json(resp) + + +def cmd_direct_offline_logs(client, args): + """获取直链离线日志""" + params = {} + if args.file_id: + params["fileID"] = args.file_id + if args.limit: + params["limit"] = args.limit + if args.offset: + params["offset"] = args.offset + resp = client._get("/api/v1/direct-link/offline/logs", params=params) + print_json(resp) + + +def cmd_direct_traffic_logs(client, args): + """获取直链流量日志""" + params = {} + if args.file_id: + params["fileID"] = args.file_id + if args.limit: + params["limit"] = args.limit + if args.offset: + params["offset"] = args.offset + resp = client._get("/api/v1/direct-link/log", params=params) + print_json(resp) + + +# ─── 图床管理 ──────────────────────────────────────────────────────────────── +def cmd_oss_list(client, args): + """列出图床文件""" + body = {"parentFileID": args.parent_id, "limit": args.limit} + if args.last_file_id: + body["lastFileId"] = args.last_file_id + if args.search: + body["searchData"] = args.search + resp = client._post("/api/v1/oss/file/list", body=body) + print_json(resp) + + +def cmd_oss_detail(client, args): + """获取图床文件详情""" + resp = client._get("/api/v1/oss/file/detail", params={"fileID": args.file_id}) + print_json(resp) + + +def cmd_oss_delete(client, args): + """删除图床文件""" + resp = client._post("/api/v1/oss/file/delete", body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_oss_move(client, args): + """移动图床文件""" + resp = client._post("/api/v1/oss/file/move", + body={"fileID": args.file_id, "targetParentFileID": args.target_parent_id}) + print_json(resp) + + +def cmd_oss_copy(client, args): + """复制图床文件""" + resp = client._post("/api/v1/oss/source/copy", + body={"fileIDs": args.file_ids, "targetParentFileID": args.target_parent_id}) + print_json(resp) + + +def cmd_oss_copy_process(client, args): + """查询图床复制进度""" + resp = client._get("/api/v1/oss/source/copy/process", params={"taskID": args.task_id}) + print_json(resp) + + +def cmd_oss_copy_fail(client, args): + """查询图床复制失败详情""" + resp = client._get("/api/v1/oss/source/copy/fail", params={"taskID": args.task_id}) + print_json(resp) + + +# ─── 视频转码 ──────────────────────────────────────────────────────────────── +def cmd_transcode_list(client, args): + """列出转码视频""" + params = {} + if args.parent_file_id: + params["parentFileID"] = args.parent_file_id + params["limit"] = args.limit + if args.last_file_id: + params["lastFileId"] = args.last_file_id + resp = client._get("/api/v1/video/transcode/list", params=params) + print_json(resp) + + +def cmd_transcode_folder(client, args): + """获取转码文件夹信息""" + resp = client._post("/api/v1/transcode/folder/info", + body={"parentFileID": args.parent_file_id}) + print_json(resp) + + +def cmd_transcode_resolutions(client, args): + """获取视频可用分辨率""" + resp = client._post("/api/v1/transcode/video/resolutions", + body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_transcode_start(client, args): + """开始视频转码""" + body = {"fileID": args.file_id, "resolutions": args.resolutions} + resp = client._post("/api/v1/transcode/video", body=body) + print_json(resp) + + +def cmd_transcode_record(client, args): + """获取转码记录""" + resp = client._post("/api/v1/transcode/video/record", + body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_transcode_result(client, args): + """获取转码结果""" + resp = client._post("/api/v1/transcode/video/result", + body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_transcode_delete(client, args): + """删除转码结果""" + resp = client._post("/api/v1/transcode/delete", + body={"fileID": args.file_id}) + print_json(resp) + + +def cmd_transcode_download(client, args): + """下载转码文件""" + resp = client._post("/api/v1/transcode/file/download", + body={"fileID": args.file_id}) + data = client._extract(resp) + if data and "downloadUrl" in data: + url = data["downloadUrl"] + filename = args.output or data.get("fileName", "transcoded_video") + client._download_file(url, filename) + else: + print_json(resp) + + +def cmd_transcode_download_m3u8(client, args): + """下载转码M3U8文件""" + resp = client._post("/api/v1/transcode/m3u8_ts/download", + body={"fileID": args.file_id}) + data = client._extract(resp) + if data and "downloadUrl" in data: + url = data["downloadUrl"] + filename = args.output or "transcoded_video.m3u8" + client._download_file(url, filename) + else: + print_json(resp) + + +def cmd_transcode_download_all(client, args): + """下载所有转码结果""" + resp = client._post("/api/v1/transcode/file/download/all", + body={"fileID": args.file_id}) + data = client._extract(resp) + if data and "downloadUrl" in data: + url = data["downloadUrl"] + filename = args.output or "transcoded_all.zip" + client._download_file(url, filename) + else: + print_json(resp) + + +def cmd_transcode_upload_cloud(client, args): + """从云盘上传到转码""" + resp = client._post("/api/v1/transcode/upload/from_cloud_disk", + body={"fileID": args.file_id, "parentFileID": args.parent_file_id}) + print_json(resp) + + +# ─── 保险箱 ──────────────────────────────────────────────────────────────── +def cmd_safebox_unlock(client, args): + """解锁保险箱""" + resp = client._get("/api/v1/safebox/id", params={"password": args.password}) + print_json(resp) + + +# ─── 缓存管理 ───────────────────────────────────────────────────────────── +def cmd_cache_list(client, args): + """列出所有缓存文件""" + if not os.path.exists(CACHE_DIR): + print_warn("缓存目录不存在") + return + + cache_files = [] + total_size = 0 + for f in os.listdir(CACHE_DIR): + if f.endswith(".json"): + path = os.path.join(CACHE_DIR, f) + size = os.path.getsize(path) + mtime = os.path.getmtime(path) + age = time.time() - mtime + cache_files.append({ + "filename": f, + "size": human_size(size), + "size_bytes": size, + "age_seconds": int(age), + "age_human": f"{int(age // 3600)}小时{int((age % 3600) // 60)}分钟" if age >= 3600 else f"{int(age // 60)}分钟{int(age % 60)}秒", + "root_id": f.replace("files_", "").replace(".json", ""), + }) + total_size += size + + if not cache_files: + print_warn("没有缓存文件") + return + + print(f"\n\033[36m📂 缓存目录: {CACHE_DIR}\033[0m") + print(f"\033[36m📊 共 {len(cache_files)} 个缓存文件,总计 {human_size(total_size)}\033[0m\n") + + for cf in sorted(cache_files, key=lambda x: x["age_seconds"]): + print(f" 📄 {cf['filename']}") + print(f" 文件夹ID: {cf['root_id']} | 大小: {cf['size']} | 创建于: {cf['age_human']}前") + print() + + +def cmd_cache_clear(client, args): + """清除缓存""" + if not os.path.exists(CACHE_DIR): + print_warn("缓存目录不存在,无需清除") + return + + if args.root_id: + # 清除指定文件夹的缓存 + cache_file = os.path.join(CACHE_DIR, f"files_{args.root_id}.json") + if not os.path.exists(cache_file): + print_warn(f"没有找到文件夹 {args.root_id} 的缓存") + return + + if not args.yes: + confirm = input(f" 确认清除文件夹 {args.root_id} 的缓存?(y/N): ").strip().lower() + if confirm != "y": + print("已取消") + return + + os.remove(cache_file) + print_success(f"已清除文件夹 {args.root_id} 的缓存") + else: + # 清除全部缓存 + cache_files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json")] + if not cache_files: + print_warn("没有缓存文件") + return + + if not args.yes: + confirm = input(f" 确认清除全部 {len(cache_files)} 个缓存文件?(y/N): ").strip().lower() + if confirm != "y": + print("已取消") + return + + for f in cache_files: + os.remove(os.path.join(CACHE_DIR, f)) + print_success(f"已清除全部 {len(cache_files)} 个缓存文件") + + +def cmd_cache_info(client, args): + """显示缓存目录信息""" + if not os.path.exists(CACHE_DIR): + print(f"缓存目录: {CACHE_DIR}") + print("状态: 目录不存在") + return + + cache_files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json")] + total_size = sum(os.path.getsize(os.path.join(CACHE_DIR, f)) for f in cache_files) + + info = { + "缓存目录": CACHE_DIR, + "目录存在": "✅ 是", + "缓存文件数": len(cache_files), + "总大小": human_size(total_size), + "缓存有效期": "5 分钟 (300秒)", + } + print_json(info) + + +# ─── 高级命令 ──────────────────────────────────────────────────────────────── +def cmd_file_tree(client, args): + """以树状结构显示目录内容""" + parent_id = args.parent_id + max_depth = getattr(args, "max_depth", None) + + # BFS 获取所有文件夹和文件 + all_files, all_folders = bfs_list_files(client, parent_id, use_cache=False, + show_progress=True, max_depth=max_depth) + + # 构建树结构:将文件和文件夹挂到对应路径下 + tree = {} # path -> [(name, size, is_dir)] + tree[""] = [] + + for f in all_folders: + p = f["path"] + if p not in tree: + tree[p] = [] + tree[p].append((f["filename"], 0, True, f["fileId"])) + + for f in all_files: + p = f["path"] + if p not in tree: + tree[p] = [] + tree[p].append((f["filename"], f.get("size", 0), False, f["fileId"])) + + # 渲染树 + def _render(prefix, path, items): + items.sort(key=lambda x: (not x[2], x[0])) # 文件夹优先 + for i, (name, size, is_dir, fid) in enumerate(items): + is_last = (i == len(items) - 1) + connector = "└── " if is_last else "├── " + folder_icon = "📁" if is_dir else "📄" + size_str = f" ({human_size(size)})" if not is_dir and size else "" + print(f"{prefix}{connector}{folder_icon} {name}{size_str}") + + if is_dir: + child_path = f"{path}/{name}" if path else name + children = tree.get(child_path, []) + if children: + ext = " " if is_last else "│ " + _render(prefix + ext, child_path, children) + + # 顶层输出 + root_items = tree.get("", []) + total_files = len(all_files) + total_folders = len(all_folders) + + if JSON_OUTPUT: + json_tree = {"folders": total_folders, "files": total_files, "children": []} + for name, size, is_dir, fid in root_items: + node = {"name": name, "size": size, "is_dir": is_dir, "fileId": fid} + if is_dir: + child_path = name + node["children"] = tree.get(child_path, []) + json_tree["children"].append(node) + print_json(json_tree) + return + + print(f"\n\033[36m🌳 目录树 (共 {total_folders} 个文件夹, {total_files} 个文件)\033[0m\n") + _render("", "", root_items) + + +def cmd_offline_list(client, args): + """列出离线下载任务 + + 注意:123云盘 API 没有提供批量列出离线任务的接口。 + 离线任务需要通过 offline create 获取 task_id 后, + 使用 offline progress 逐个查询进度。 + """ + if JSON_OUTPUT: + print_json({ + "message": "123云盘 API 不支持列出离线下载任务", + "hint": "请使用 offline create 创建任务,然后用 offline progress 查询进度" + }) + return + + print_warn("123云盘 API 没有提供列出所有离线下载任务的接口") + print() + print(" 离线下载流程:") + print(" 1. 使用 offline create 创建离线下载任务,获取 task_id") + print(" 2. 使用 offline progress 查询任务进度") + print() + print(" 示例:") + print(" 123yunpan offline create --url <磁力链接或HTTP链接>") + print(" 123yunpan offline progress --task-id ") + + +def cmd_share_export(client, args): + """导出所有分享链接到 Excel 或 JSON""" + output_file = args.output + if not output_file: + print_error("请使用 --output 指定输出文件路径") + return + + # 分页获取所有分享 + all_shares = [] + last_id = "" + page = 1 + print(f"\033[36m📦 正在导出分享链接...\033[0m") + + while True: + params = {"limit": 100} + if last_id: + params["lastShareId"] = last_id + resp = client._get("/api/v1/share/list", params=params) + data = client._extract(resp) + if data and "shareList" in data: + shares = data["shareList"] + all_shares.extend(shares) + last_id = str(data.get("lastShareId", "")) + print(f"\r 📥 第 {page} 页: 已获取 {len(all_shares)} 条分享", end="", flush=True) + if len(shares) < 100 or not last_id or last_id == "-1": + break + else: + break + page += 1 + time.sleep(0.3) + + print(f"\n\033[36m📊 共获取 {len(all_shares)} 条分享\033[0m") + + if not all_shares: + print_warn("没有找到任何分享") + return + + # 构建导出数据 + # 官方 share/list 返回字段:shareId, shareKey, shareName, expiration, expired, + # sharePwd, trafficSwitch, trafficLimitSwitch, trafficLimit, bytesCharge, + # previewCount, downloadCount, saveCount + url_format = getattr(args, "url_format", "official") + export_data = [] + for s in all_shares: + share_key = s.get("shareKey", "") + share_url = _share_url(client, share_key, url_format) if share_key else "" + export_data.append({ + "shareId": s.get("shareId", ""), + "shareName": s.get("shareName", ""), + "shareKey": share_key, + "shareUrl": share_url, + "expiration": s.get("expiration", ""), + "expired": "已失效" if s.get("expired") == 1 else "有效", + "previewCount": s.get("previewCount", 0), + "downloadCount": s.get("downloadCount", 0), + "saveCount": s.get("saveCount", 0), + "bytesCharge": human_size(s.get("bytesCharge", 0)), + }) + + if JSON_OUTPUT: + print_json(export_data) + return + + if output_file.endswith(".json"): + with open(output_file, "w", encoding="utf-8") as f: + json.dump(export_data, f, ensure_ascii=False, indent=2) + print_success(f"JSON 已保存: {output_file} ({len(export_data)} 条)") + return + + headers = ["分享ID", "名称", "分享链接", "过期时间", "状态", "预览", "下载", "转存", "流量"] + widths = [14, 32, 52, 20, 10, 10, 10, 10, 14] + rows = [[d["shareId"], d["shareName"], d["shareUrl"], d["expiration"], d["expired"], + d["previewCount"], d["downloadCount"], d["saveCount"], d["bytesCharge"]] + for d in export_data] + write_xlsx(output_file, "分享链接", headers, rows, widths, hyperlink_cols=(2,)) + print_success(f"Excel 已保存: {output_file} ({len(rows)} 条)") + + +def cmd_config_export(client, args): + """导出配置到 JSON 文件(不包含 access_token)""" + output_file = args.output + if not output_file: + print_error("请使用 --output 指定输出文件路径") + return + + config_data = { + "client_id": client.config.client_id or "", + "client_secret": client.config.client_secret or "", + } + + if JSON_OUTPUT: + print_json({"message": "配置已导出", "output": output_file}) + return + + with open(output_file, "w", encoding="utf-8") as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + print_success(f"配置已导出到: {output_file}") + print(f" client_id: {config_data['client_id'][:8]}..." if len(config_data["client_id"]) > 8 else f" client_id: {config_data['client_id']}") + print(f" client_secret: {'*' * 8}...") + + +def cmd_config_import(client, args): + """从 JSON 文件导入配置""" + input_file = args.file + if not input_file: + print_error("请使用 --file 指定配置文件路径") + return + + if not os.path.exists(input_file): + print_error(f"文件不存在: {input_file}") + return + + try: + with open(input_file, "r", encoding="utf-8") as f: + config_data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + print_error(f"读取配置文件失败: {e}") + return + + if JSON_OUTPUT: + print_json({"message": "配置已导入", "client_id": config_data.get("client_id", "")}) + return + + if config_data.get("client_id"): + client.config.client_id = config_data["client_id"] + print_success(f"已设置 client_id: {config_data['client_id'][:8]}..." if len(config_data.get("client_id", "")) > 8 else f"已设置 client_id: {config_data['client_id']}") + if config_data.get("client_secret"): + client.config.client_secret = config_data["client_secret"] + print_success("已设置 client_secret: " + "*" * 8) + + client.config.save() + print_success(f"配置已保存到 {CONFIG_PATH}") + print(" 运行 123yunpan setup 来获取新的 access_token") + + +def cmd_file_download_batch(client, args): + """批量下载多个文件""" + file_ids = args.file_ids + output_dir = args.output_dir or "." + + if not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + total = len(file_ids) + success = 0 + fail = 0 + + if JSON_OUTPUT: + results = [] + + print(f"\033[36m📥 批量下载 {total} 个文件到 {output_dir}\033[0m\n") + + for i, file_id in enumerate(file_ids): + print(f"\r [{i+1}/{total}] 获取文件 {file_id} 的下载链接...", end="", flush=True) + + detail_data = client._extract( + client._get("/api/v1/file/detail", params={"fileID": file_id})) or {} + filename = detail_data.get("filename") or f"{file_id}" + file_size = detail_data.get("size", 0) + + data = get_download_info(client, file_id) + if data is None: + msg = "获取下载链接失败:文件不存在(5066) 或 自用下载流量不足(5113)" + print(f"\n ❌ {filename}:{msg}") + fail += 1 + if JSON_OUTPUT: + results.append({"fileId": file_id, "filename": filename, "status": "error", "message": msg}) + continue + + download_url = data["downloadUrl"] + save_path = os.path.join(output_dir, filename) + + # 文件名冲突时加 file_id 后缀,避免覆盖 + if os.path.exists(save_path): + base, ext = os.path.splitext(filename) + save_path = os.path.join(output_dir, f"{base}_{file_id}{ext}") + + print(f"\n 📄 下载: {filename} ({human_size(file_size)})") + try: + client._download_file(download_url, save_path, file_size) + except HTTPError as e: + print(f"\n ❌ {filename} 下载失败:HTTP {e.code}") + fail += 1 + if JSON_OUTPUT: + results.append({"fileId": file_id, "filename": filename, "status": "error", + "message": f"HTTP {e.code}"}) + continue + except URLError as e: + print(f"\n ❌ {filename} 下载失败:{e.reason}") + fail += 1 + if JSON_OUTPUT: + results.append({"fileId": file_id, "filename": filename, "status": "error", + "message": str(e.reason)}) + continue + + success += 1 + if JSON_OUTPUT: + results.append({"fileId": file_id, "filename": filename, "path": save_path, + "status": "success", "size": file_size}) + + print(f"\n\033[36m{'='*50}\033[0m") + print(f"\033[32m✅ 完成!成功 {success} 个,失败 {fail} 个\033[0m") + + if JSON_OUTPUT: + print_json({"total": total, "success": success, "fail": fail, "results": results}) + + +def cmd_file_storage_report(client, args): + """分析存储空间使用情况""" + root_id = args.parent_id + use_cache = not args.no_cache + top_n = getattr(args, "top_n", 10) or 10 + + all_files, all_folders = bfs_list_files(client, root_id, use_cache=use_cache, + show_progress=True) + + if not all_files: + if JSON_OUTPUT: + print_json({"total_files": 0, "total_size": 0, "folders": {}, "top_files": []}) + else: + print_warn("没有找到任何文件") + return + + # 按顶级文件夹聚合 + folder_stats = {} # folder_name -> {"size": bytes, "count": int} + total_size = 0 + + for f in all_files: + size = f.get("size", 0) + total_size += size + + path_parts = f["path"].split("/") if f["path"] else [] + top_folder = path_parts[0] if path_parts else "(根目录)" + + if top_folder not in folder_stats: + folder_stats[top_folder] = {"size": 0, "count": 0} + folder_stats[top_folder]["size"] += size + folder_stats[top_folder]["count"] += 1 + + # 按大小排序的顶级文件夹 + sorted_folders = sorted(folder_stats.items(), key=lambda x: x[1]["size"], reverse=True) + + # 按大小排序的文件 + sorted_files = sorted(all_files, key=lambda x: x.get("size", 0), reverse=True)[:top_n] + + if JSON_OUTPUT: + json_report = { + "total_files": len(all_files), + "total_folders": len(all_folders), + "total_size": total_size, + "total_size_human": human_size(total_size), + "folders": [ + {"name": name, "size": stat["size"], "size_human": human_size(stat["size"]), + "count": stat["count"]} + for name, stat in sorted_folders + ], + "top_files": [ + {"filename": f["filename"], "path": f["path"], "fileId": f["fileId"], + "size": f.get("size", 0), "size_human": human_size(f.get("size", 0))} + for f in sorted_files + ], + } + print_json(json_report) + return + + # 漂亮打印 + print(f"\n\033[36m{'='*60}\033[0m") + print(f"\033[36m📊 存储空间分析报告\033[0m") + print(f"\033[36m{'='*60}\033[0m") + print(f"\n 📁 总文件数: {len(all_files)}") + print(f" 📂 总文件夹数: {len(all_folders)}") + print(f" 💾 总大小: {human_size(total_size)}") + + print(f"\n\033[36m📁 文件夹使用情况 (按大小排序)\033[0m\n") + for name, stat in sorted_folders: + bar_len = 30 + pct = stat["size"] / total_size if total_size > 0 else 0 + filled = int(bar_len * pct) + bar = "█" * filled + "░" * (bar_len - filled) + print(f" {name:.<30} {bar} {human_size(stat['size']):>10} ({stat['count']} 个文件)") + + print(f"\n\033[36m📄 最大的 {min(top_n, len(sorted_files))} 个文件\033[0m\n") + for f in sorted_files: + full_path = f"{f['path']}/{f['filename']}" if f["path"] else f["filename"] + print(f" {human_size(f.get('size', 0)):>10} [{f['fileId']}] {full_path}") + + print() + + +# ─── 帮助信息格式化 ────────────────────────────────────────────────────────── +def format_file(f): + """格式化文件信息(用于列表输出)""" + ftype = "📁" if f.get("type") == 1 else "📄" + return { + "type": ftype, + "fileId": f.get("fileId"), + "filename": f.get("filename"), + "size": human_size(f.get("size", 0)), + "size_bytes": f.get("size"), + "parentFileID": f.get("parentFileID"), + "createAt": f.get("createAt"), + "trashed": f.get("trashed"), + } + + +# ─── 主程序 ────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser( + description="123云盘 (123 Cloud Drive) 开放平台 API 命令行工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +使用示例: + %(prog)s setup --client-id YOUR_ID --client-secret YOUR_SECRET + %(prog)s config check + %(prog)s space + %(prog)s file list --parent-id 0 + %(prog)s file list --parent-id 0 --search "文档" --all-pages + %(prog)s file detail --file-id 123456 + %(prog)s file mkdir --name "新文件夹" --parent-id 0 + %(prog)s file download --file-id 123456 + %(prog)s upload --file /path/to/file --parent-id 0 + %(prog)s share create --name "分享" --file-ids 123,456 --expire 7 + %(prog)s share create-batch --parent-id 27172609 --expire 0 + %(prog)s offline create --url "magnet:?xt=..." --parent-id 0 + %(prog)s user-info + """, + ) + parser.add_argument("--no-color", action="store_true", help="禁用彩色输出(也支持 NO_COLOR 环境变量)") + parser.add_argument("--json", action="store_true", dest="json_output", help="JSON 机器可读输出(单行,无缩进)") + subparsers = parser.add_subparsers(dest="command", help="子命令") + + # ── setup ────────────────────────────────────────────── + p_setup = subparsers.add_parser("setup", help="配置客户端并获取 access_token") + p_setup.add_argument("--client-id", help="Client ID") + p_setup.add_argument("--client-secret", help="Client Secret") + p_setup.set_defaults(func=cmd_setup) + + # ── user-info ────────────────────────────────────────── + p_user = subparsers.add_parser("user-info", help="获取用户信息") + p_user.set_defaults(func=cmd_user_info) + + # ── space ────────────────────────────────────────────── + p_space = subparsers.add_parser("space", help="空间占用报告(专业空间 / 标准空间)") + p_space.set_defaults(func=cmd_space) + + # ── file 子命令组 ────────────────────────────────────── + p_file = subparsers.add_parser("file", help="文件管理") + file_sub = p_file.add_subparsers(dest="file_cmd", help="文件操作") + + # file list + p_fl = file_sub.add_parser("list", help="列出文件") + p_fl.add_argument("--parent-id", type=int, default=0, help="父文件夹ID (默认0=根目录)") + p_fl.add_argument("--limit", type=int, default=DEFAULT_LIMIT, + help=f"每页数量 (默认100,上限 {MAX_LIMIT})") + p_fl.add_argument("--last-file-id", help="上一页最后文件ID (分页)") + p_fl.add_argument("--search", help="搜索关键词") + p_fl.add_argument("--search-mode", type=int, default=0, choices=[0, 1], help="搜索模式: 0=模糊 1=精确") + p_fl.add_argument("--all-pages", action="store_true", help="自动获取所有分页") + p_fl.add_argument("--trashed", action="store_true", help="只显示回收站中的文件") + p_fl.add_argument("--show-trashed", action="store_true", help="同时显示回收站中的文件") + p_fl.set_defaults(func=cmd_file_list) + + # file search + p_fs = file_sub.add_parser("search", help="跨文件夹递归搜索文件") + p_fs.add_argument("keyword", help="搜索关键词(文件名包含即匹配)") + p_fs.add_argument("--parent-id", type=int, default=0, help="搜索起点文件夹ID (默认0=根目录)") + p_fs.add_argument("--max-depth", type=int, default=5, help="最大搜索深度 (默认5)") + p_fs.add_argument("--no-cache", action="store_true", help="不使用缓存,强制重新遍历") + p_fs.set_defaults(func=cmd_file_search) + + # file detail + p_fd = file_sub.add_parser("detail", help="获取文件详情") + p_fd.add_argument("--file-id", required=True, type=int, help="文件ID") + p_fd.set_defaults(func=cmd_file_detail) + + # file details-multi + p_fdm = file_sub.add_parser("details-multi", help="批量获取文件详情") + p_fdm.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_fdm.set_defaults(func=cmd_file_details_multi) + + # file mkdir + p_mkdir = file_sub.add_parser("mkdir", help="创建文件夹") + p_mkdir.add_argument("--name", required=True, help="文件夹名称") + p_mkdir.add_argument("--parent-id", type=int, default=0, help="父文件夹ID") + p_mkdir.set_defaults(func=cmd_mkdir) + + # file rename + p_rn = file_sub.add_parser("rename", help="重命名文件") + p_rn.add_argument("--file-id", required=True, type=int, help="文件ID") + p_rn.add_argument("--new-name", required=True, help="新名称") + p_rn.set_defaults(func=cmd_rename) + + # file rename-batch + p_rnb = file_sub.add_parser("rename-batch", help="批量重命名文件") + p_rnb.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_rnb.add_argument("--names", required=True, nargs="+", help="新名称列表 (逗号或空格分隔,与文件ID一一对应)") + p_rnb.set_defaults(func=cmd_rename_batch) + + # file move + p_mv = file_sub.add_parser("move", help="移动文件") + p_mv.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_mv.add_argument("--target-parent-id", required=True, type=int, help="目标文件夹ID") + p_mv.set_defaults(func=cmd_move) + + # file copy + p_cp = file_sub.add_parser("copy", help="复制文件") + p_cp.add_argument("--file-id", required=True, type=int, help="文件ID") + p_cp.add_argument("--target-parent-id", required=True, type=int, help="目标文件夹ID") + p_cp.set_defaults(func=cmd_copy) + + # file copy-batch + p_cpb = file_sub.add_parser("copy-batch", help="批量复制文件") + p_cpb.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_cpb.add_argument("--target-parent-id", required=True, type=int, help="目标文件夹ID") + p_cpb.set_defaults(func=cmd_copy_batch) + + # file copy-progress + p_cpp = file_sub.add_parser("copy-progress", help="查询批量复制进度") + p_cpp.add_argument("--task-id", required=True, help="任务ID") + p_cpp.set_defaults(func=cmd_copy_progress) + + # file trash + p_trash = file_sub.add_parser("trash", help="移入回收站") + p_trash.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_trash.add_argument("--dry-run", action="store_true", help="预览模式:只显示将被删除的文件,不执行操作") + p_trash.set_defaults(func=cmd_trash) + + # file recover + p_rec = file_sub.add_parser("recover", help="从回收站恢复文件") + p_rec.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_rec.set_defaults(func=cmd_recover) + + # file recover-by-path + p_rbp = file_sub.add_parser("recover-by-path", help="按路径恢复文件") + p_rbp.add_argument("--file-id", required=True, type=int, help="文件ID") + p_rbp.add_argument("--target-parent-id", required=True, type=int, help="目标父文件夹ID") + p_rbp.add_argument("--filename", required=True, help="文件名") + p_rbp.set_defaults(func=cmd_recover_by_path) + + # file download + p_dl = file_sub.add_parser("download", help="下载文件") + p_dl.add_argument("--file-id", required=True, type=int, help="文件ID") + p_dl.add_argument("--output", help="保存路径 (默认使用原始文件名)") + p_dl.set_defaults(func=cmd_download) + + # file tree + p_ft = file_sub.add_parser("tree", help="显示文件夹树形结构") + p_ft.add_argument("--parent-id", type=int, default=0, help="父文件夹ID (默认0=根目录)") + p_ft.add_argument("--max-depth", type=int, default=3, help="最大显示深度 (默认3)") + p_ft.set_defaults(func=cmd_file_tree) + + # file download-batch + p_fdb = file_sub.add_parser("download-batch", help="批量下载文件") + p_fdb.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_fdb.add_argument("--output-dir", default=".", help="输出目录 (默认当前目录)") + p_fdb.set_defaults(func=cmd_file_download_batch) + + # file storage-report + p_fsr = file_sub.add_parser("storage-report", help="存储使用报告") + p_fsr.add_argument("--parent-id", type=int, default=0, help="父文件夹ID (默认0=根目录)") + p_fsr.add_argument("--top", type=int, default=20, help="显示最大的 Top N 文件 (默认20)") + p_fsr.set_defaults(func=cmd_file_storage_report) + + # ── upload ───────────────────────────────────────────── + p_up = subparsers.add_parser("upload", help="上传文件 (V2)") + p_up.add_argument("--file", required=True, help="本地文件路径") + p_up.add_argument("--parent-id", default="0", help="目标文件夹ID (默认0=根目录)") + p_up.add_argument("--filename", help="指定上传后的文件名 (默认使用原始文件名)") + p_up.add_argument("--duplicate", type=int, default=None, choices=[1, 2], + help="重名处理: 1=重命名 2=覆盖 (默认不发送=覆盖)") + p_up.set_defaults(func=cmd_upload) + + # ── share 子命令组 ───────────────────────────────────── + p_share = subparsers.add_parser("share", help="分享管理") + share_sub = p_share.add_subparsers(dest="share_cmd", help="分享操作") + + # share create + p_sc = share_sub.add_parser("create", help="创建分享") + p_sc.add_argument("--name", required=True, help="分享名称") + p_sc.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_sc.add_argument("--expire", type=int, default=7, choices=[0, 1, 7, 30], + help="有效期: 0=永久 1=1天 7=7天 30=30天 (默认7)") + p_sc.add_argument("--pwd", help="分享密码 (可选)") + p_sc.add_argument("--traffic-switch", type=int, help="流量限制开关: 0=关 1=开") + p_sc.add_argument("--traffic-limit-switch", type=int, help="流量限制值开关: 0=关 1=开") + p_sc.add_argument("--traffic-limit", type=int, help="流量限制值 (字节)") + p_sc.add_argument("--url-format", choices=["official", "web", "uid", "short"], + default="official", + help="分享链接格式: official(默认,官方 https://{uid}.share.123pan.cn/123pan/{key}) " + "或 web (https://www.123pan.com/s/{key})") + p_sc.set_defaults(func=cmd_share_create) + + # share list + p_sl = share_sub.add_parser("list", help="列出分享") + p_sl.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="每页数量") + p_sl.add_argument("--last-share-id", help="上一页最后分享ID") + p_sl.add_argument("--all-pages", action="store_true", help="自动获取所有分页") + p_sl.set_defaults(func=cmd_share_list) + + # share update + p_su = share_sub.add_parser("update", help="更新分享设置") + p_su.add_argument("--share-ids", required=True, type=parse_int_list, help="分享ID (逗号分隔,如: 1,2,3)") + p_su.add_argument("--traffic-switch", type=int, help="流量限制开关") + p_su.add_argument("--traffic-limit-switch", type=int, help="流量限制值开关") + p_su.add_argument("--traffic-limit", type=int, help="流量限制值") + p_su.set_defaults(func=cmd_share_update) + + # share pay-create + p_spc = share_sub.add_parser("pay-create", help="创建付费分享") + p_spc.add_argument("--name", required=True, help="分享名称") + p_spc.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_spc.add_argument("--amount", required=True, type=int, help="付费金额 (1-1000)") + p_spc.add_argument("--is-reward", action="store_true", help="是否为打赏模式") + p_spc.add_argument("--desc", help="资源描述") + p_spc.set_defaults(func=cmd_share_pay_create) + + # share pay-list + p_spl = share_sub.add_parser("pay-list", help="列出付费分享") + p_spl.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="每页数量") + p_spl.add_argument("--last-share-id", help="上一页最后分享ID") + p_spl.add_argument("--all-pages", action="store_true", help="自动获取所有分页") + p_spl.set_defaults(func=cmd_share_pay_list) + + # share pay-update + p_spu = share_sub.add_parser("pay-update", help="更新付费分享设置") + p_spu.add_argument("--share-ids", required=True, type=parse_int_list, help="分享ID (逗号分隔,如: 1,2,3)") + p_spu.add_argument("--traffic-switch", type=int, help="流量限制开关") + p_spu.add_argument("--traffic-limit-switch", type=int, help="流量限制值开关") + p_spu.add_argument("--traffic-limit", type=int, help="流量限制值") + p_spu.set_defaults(func=cmd_share_pay_update) + + # share create-batch + p_scb = share_sub.add_parser("create-batch", help="批量创建分享 (递归遍历文件夹)") + p_scb.add_argument("--parent-id", required=True, type=int, help="根文件夹ID") + p_scb.add_argument("--expire", type=int, default=0, choices=[0, 1, 7, 30], + help="有效期: 0=永久 1=1天 7=7天 30=30天 (默认永久)") + p_scb.add_argument("--output", "-o", help="输出文件路径 (.xlsx 默认, 或 .json);" + "不指定且链接数超过阈值时自动生成 xlsx") + p_scb.add_argument("--no-recursive", action="store_true", help="不递归,只处理直接子项文件") + p_scb.add_argument("--no-cache", action="store_true", help="不使用缓存,强制重新遍历") + p_scb.add_argument("--url-format", choices=["official", "web", "uid", "short"], + default="official", + help="分享链接格式: official(默认) 或 web") + p_scb.add_argument("--excel-threshold", type=int, default=SHARE_EXCEL_THRESHOLD, + help=f"链接数超过该值时自动生成 Excel (默认 {SHARE_EXCEL_THRESHOLD})") + p_scb.add_argument("--no-excel", action="store_true", + help="关闭自动 Excel,超过阈值时也只打印链接") + p_scb.add_argument("--resume", action="store_true", + help="断点续跑:跳过上次已完成的文件,仅处理剩余部分") + p_scb.set_defaults(func=cmd_share_create_batch) + + # share stats + p_ss = share_sub.add_parser("stats", help="分享链接统计汇总") + p_ss.add_argument("--top", type=int, default=10, help="显示下载量 Top N (默认10)") + p_ss.set_defaults(func=cmd_share_stats) + + # share delete-batch + p_sdb = share_sub.add_parser("delete-batch", help="批量删除分享链接") + p_sdb.add_argument("--share-ids", type=parse_int_list, help="分享ID (逗号分隔,如: 1,2,3)") + p_sdb.add_argument("--all", action="store_true", help="删除所有分享链接") + p_sdb.add_argument("-y", "--yes", action="store_true", help="跳过确认提示") + p_sdb.add_argument("--dry-run", action="store_true", help="预览模式:只显示将被删除的分享,不执行操作") + p_sdb.set_defaults(func=cmd_share_delete_batch) + + # share export + p_se = share_sub.add_parser("export", help="导出分享列表到文件") + p_se.add_argument("--output", "-o", required=True, help="输出文件路径 (.xlsx 或 .json)") + p_se.add_argument("--url-format", choices=["official", "web", "uid", "short"], + default="official", help="分享链接格式 (默认 official)") + p_se.set_defaults(func=cmd_share_export) + + # ── offline 子命令组 ─────────────────────────────────── + p_off = subparsers.add_parser("offline", help="离线下载") + off_sub = p_off.add_subparsers(dest="offline_cmd", help="离线下载操作") + + # offline create + p_oc = off_sub.add_parser("create", help="创建离线下载任务") + p_oc.add_argument("--url", required=True, help="下载链接 (HTTP/FTP/磁力链接)") + p_oc.add_argument("--parent-id", type=int, default=0, help="保存到的文件夹ID") + p_oc.add_argument("--filename", help="指定文件名 (可选)") + p_oc.set_defaults(func=cmd_offline_create) + + # offline progress + p_op = off_sub.add_parser("progress", help="查询离线下载进度") + p_op.add_argument("--task-id", required=True, help="任务ID") + p_op.set_defaults(func=cmd_offline_progress) + + # offline list + p_ol = off_sub.add_parser("list", help="列出离线下载任务") + p_ol.set_defaults(func=cmd_offline_list) + + # ── direct-link 子命令组 ─────────────────────────────── + p_direct = subparsers.add_parser("direct-link", help="直链管理") + direct_sub = p_direct.add_subparsers(dest="direct_cmd", help="直链操作") + + # direct enable + p_de = direct_sub.add_parser("enable", help="启用文件直链") + p_de.add_argument("--file-id", required=True, type=int, help="文件ID") + p_de.set_defaults(func=cmd_direct_enable) + + # direct disable + p_dd = direct_sub.add_parser("disable", help="禁用文件直链") + p_dd.add_argument("--file-id", required=True, type=int, help="文件ID") + p_dd.set_defaults(func=cmd_direct_disable) + + # direct url + p_du = direct_sub.add_parser("url", help="获取文件直链URL") + p_du.add_argument("--file-id", required=True, type=int, help="文件ID") + p_du.set_defaults(func=cmd_direct_url) + + # direct short-url + p_dsu = direct_sub.add_parser("short-url", help="生成短直链URL(更隐私,不暴露文件名)") + p_dsu.add_argument("--file-id", required=True, type=int, help="文件ID") + p_dsu.set_defaults(func=cmd_direct_short_url) + + # direct cache-refresh + p_dcr = direct_sub.add_parser("cache-refresh", help="刷新直链缓存") + p_dcr.add_argument("--file-id", required=True, type=int, help="文件ID") + p_dcr.set_defaults(func=cmd_direct_cache_refresh) + + # direct ip-list + p_dil = direct_sub.add_parser("ip-list", help="获取IP白名单") + p_dil.set_defaults(func=cmd_direct_ip_list) + + # direct ip-switch + p_dis = direct_sub.add_parser("ip-switch", help="切换IP白名单开关") + p_dis.add_argument("--switch", required=True, type=int, choices=[0, 1], + dest="switch_state", help="开关状态: 0=关闭 1=开启") + p_dis.set_defaults(func=cmd_direct_ip_switch) + + # direct ip-update + p_diu = direct_sub.add_parser("ip-update", help="更新IP白名单") + p_diu.add_argument("--ips", required=True, nargs="+", help="IP地址列表") + p_diu.set_defaults(func=cmd_direct_ip_update) + + # direct offline-logs + p_dol = direct_sub.add_parser("offline-logs", help="获取直链离线日志") + p_dol.add_argument("--file-id", type=int, help="文件ID") + p_dol.add_argument("--limit", type=int, help="每页数量") + p_dol.add_argument("--offset", type=int, help="偏移量") + p_dol.set_defaults(func=cmd_direct_offline_logs) + + # direct traffic-logs + p_dtl = direct_sub.add_parser("traffic-logs", help="获取直链流量日志") + p_dtl.add_argument("--file-id", type=int, help="文件ID") + p_dtl.add_argument("--limit", type=int, help="每页数量") + p_dtl.add_argument("--offset", type=int, help="偏移量") + p_dtl.set_defaults(func=cmd_direct_traffic_logs) + + # ── oss (图床) 子命令组 ──────────────────────────────── + p_oss = subparsers.add_parser("oss", help="图床管理") + oss_sub = p_oss.add_subparsers(dest="oss_cmd", help="图床操作") + + # oss list + p_ol = oss_sub.add_parser("list", help="列出图床文件") + p_ol.add_argument("--parent-id", type=int, default=0, help="父文件夹ID") + p_ol.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="每页数量") + p_ol.add_argument("--last-file-id", help="上一页最后文件ID") + p_ol.add_argument("--search", help="搜索关键词") + p_ol.set_defaults(func=cmd_oss_list) + + # oss detail + p_od = oss_sub.add_parser("detail", help="获取图床文件详情") + p_od.add_argument("--file-id", required=True, type=int, help="文件ID") + p_od.set_defaults(func=cmd_oss_detail) + + # oss delete + p_odel = oss_sub.add_parser("delete", help="删除图床文件") + p_odel.add_argument("--file-id", required=True, type=int, help="文件ID") + p_odel.set_defaults(func=cmd_oss_delete) + + # oss move + p_omv = oss_sub.add_parser("move", help="移动图床文件") + p_omv.add_argument("--file-id", required=True, type=int, help="文件ID") + p_omv.add_argument("--target-parent-id", required=True, type=int, help="目标文件夹ID") + p_omv.set_defaults(func=cmd_oss_move) + + # oss copy + p_ocp = oss_sub.add_parser("copy", help="复制图床文件") + p_ocp.add_argument("--file-ids", required=True, type=parse_int_list, help="文件ID (逗号分隔,如: 1,2,3)") + p_ocp.add_argument("--target-parent-id", required=True, type=int, help="目标文件夹ID") + p_ocp.set_defaults(func=cmd_oss_copy) + + # oss copy-process + p_oco = oss_sub.add_parser("copy-process", help="查询图床复制进度") + p_oco.add_argument("--task-id", required=True, help="任务ID") + p_oco.set_defaults(func=cmd_oss_copy_process) + + # oss copy-fail + p_ocf = oss_sub.add_parser("copy-fail", help="查询图床复制失败详情") + p_ocf.add_argument("--task-id", required=True, help="任务ID") + p_ocf.set_defaults(func=cmd_oss_copy_fail) + + # ── transcode (视频转码) 子命令组 ────────────────────── + p_tc = subparsers.add_parser("transcode", help="视频转码") + tc_sub = p_tc.add_subparsers(dest="transcode_cmd", help="视频转码操作") + + # transcode list + p_tcl = tc_sub.add_parser("list", help="列出转码视频") + p_tcl.add_argument("--parent-file-id", type=int, help="父文件夹ID") + p_tcl.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="每页数量") + p_tcl.add_argument("--last-file-id", help="上一页最后文件ID") + p_tcl.set_defaults(func=cmd_transcode_list) + + # transcode folder + p_tcf = tc_sub.add_parser("folder", help="获取转码文件夹信息") + p_tcf.add_argument("--parent-file-id", required=True, type=int, help="父文件夹ID") + p_tcf.set_defaults(func=cmd_transcode_folder) + + # transcode resolutions + p_tcr = tc_sub.add_parser("resolutions", help="获取视频可用分辨率") + p_tcr.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcr.set_defaults(func=cmd_transcode_resolutions) + + # transcode start + p_tcs = tc_sub.add_parser("transcode", help="开始视频转码") + p_tcs.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcs.add_argument("--resolutions", required=True, nargs="+", + help="分辨率列表 (如: 360p 480p 720p 1080p)") + p_tcs.set_defaults(func=cmd_transcode_start) + + # transcode record + p_tcrd = tc_sub.add_parser("record", help="获取转码记录") + p_tcrd.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcrd.set_defaults(func=cmd_transcode_record) + + # transcode result + p_tcres = tc_sub.add_parser("result", help="获取转码结果") + p_tcres.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcres.set_defaults(func=cmd_transcode_result) + + # transcode delete + p_tcdel = tc_sub.add_parser("delete", help="删除转码结果") + p_tcdel.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcdel.set_defaults(func=cmd_transcode_delete) + + # transcode download + p_tcdl = tc_sub.add_parser("download", help="下载转码文件") + p_tcdl.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcdl.add_argument("--output", help="保存路径") + p_tcdl.set_defaults(func=cmd_transcode_download) + + # transcode download-m3u8 + p_tcm3 = tc_sub.add_parser("download-m3u8", help="下载转码M3U8文件") + p_tcm3.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcm3.add_argument("--output", help="保存路径") + p_tcm3.set_defaults(func=cmd_transcode_download_m3u8) + + # transcode download-all + p_tca = tc_sub.add_parser("download-all", help="下载所有转码结果") + p_tca.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tca.add_argument("--output", help="保存路径") + p_tca.set_defaults(func=cmd_transcode_download_all) + + # transcode upload-cloud + p_tcuc = tc_sub.add_parser("upload-cloud", help="从云盘上传到转码") + p_tcuc.add_argument("--file-id", required=True, type=int, help="文件ID") + p_tcuc.add_argument("--parent-file-id", required=True, type=int, help="父文件夹ID") + p_tcuc.set_defaults(func=cmd_transcode_upload_cloud) + + # ── safebox (保险箱) 子命令组 ────────────────────────── + p_sb = subparsers.add_parser("safebox", help="保险箱管理") + sb_sub = p_sb.add_subparsers(dest="safebox_cmd", help="保险箱操作") + + # safebox unlock + p_sbu = sb_sub.add_parser("unlock", help="解锁保险箱") + p_sbu.add_argument("--password", required=True, help="保险箱密码") + p_sbu.set_defaults(func=cmd_safebox_unlock) + + # ── cache 子命令组 ────────────────────────────────────── + p_cache = subparsers.add_parser("cache", help="缓存管理") + cache_sub = p_cache.add_subparsers(dest="cache_cmd", help="缓存操作") + + # cache list + p_cl = cache_sub.add_parser("list", help="列出所有缓存文件") + p_cl.set_defaults(func=cmd_cache_list) + + # cache clear + p_cc = cache_sub.add_parser("clear", help="清除缓存") + p_cc.add_argument("--root-id", type=int, help="清除指定文件夹的缓存 (不指定则清除全部)") + p_cc.add_argument("-y", "--yes", action="store_true", help="跳过确认提示") + p_cc.set_defaults(func=cmd_cache_clear) + + # cache info + p_ci = cache_sub.add_parser("info", help="显示缓存目录信息") + p_ci.set_defaults(func=cmd_cache_info) + + # ── config ───────────────────────────────────────────── + p_config = subparsers.add_parser("config", help="查看/验证配置") + config_sub = p_config.add_subparsers(dest="config_cmd", help="配置操作") + + # config show (默认) + def cmd_config_show(client, args): + """显示当前配置""" + cfg = { + "配置文件": CONFIG_PATH, + "Client ID": client.config.client_id[:8] + "..." if len(client.config.client_id) > 8 else client.config.client_id or "未设置", + "Client Secret": "***" if client.config.client_secret else "未设置", + "Access Token": client.config.access_token[:16] + "..." if len(client.config.access_token) > 16 else client.config.access_token or "未设置", + "Token 过期时间": client.config.expired_at or "未设置", + "配置文件存在": os.path.exists(CONFIG_PATH), + } + missing = client.config.check() + if missing: + cfg["状态"] = "⚠️ 不完整" + cfg["缺少项"] = missing + else: + cfg["状态"] = "✅ 已配置" + print_json(cfg) + + p_cs = config_sub.add_parser("show", help="显示当前配置") + p_cs.set_defaults(func=cmd_config_show) + + # config check + def cmd_config_check(client, args): + """验证配置有效性(调用 API 测试)""" + print("🔍 正在验证配置...") + missing = client.config.check() + if missing: + print_error(f"配置不完整: {', '.join(missing)}") + return + + # 测试 token 有效性 + print(f" ✅ Client ID: 已配置") + print(f" ✅ Client Secret: 已配置") + print(f" ✅ Access Token: 已配置") + + # 调用 user/info 验证 token + resp, err = _get_user_info(client) + if err: + print_error(f"Token 无效或已过期: {err}") + if "access_token" in err or "401" in err: + print_warn("请运行: python3 123yunpan.py setup 重新获取 token") + return + data = client._extract(resp) or {} + print(f"\n{_colorize('✅ Token 有效!', '32')}") + space = format_space_report(data) + print(f" 用户ID: {space['uid']} 昵称: {space['nickname']} " + f"会员: {space.get('vipLabel') or ('是' if space['vip'] else '否')}") + print(f" 总空间: {human_size(space['total_quota'])}(已用 {human_size(space['total_used'])})") + print_space_details(space) + + p_cc = config_sub.add_parser("check", help="验证配置有效性(测试 API 连接)") + p_cc.set_defaults(func=cmd_config_check) + + # config export + p_ce = config_sub.add_parser("export", help="导出配置到文件") + p_ce.add_argument("--output", "-o", required=True, help="输出文件路径") + p_ce.set_defaults(func=cmd_config_export) + + # config import + p_ci = config_sub.add_parser("import", help="从文件导入配置") + p_ci.add_argument("--file", required=True, help="配置文件路径") + p_ci.set_defaults(func=cmd_config_import) + + # 兼容:直接 `config` 等同于 `config show` + p_config.set_defaults(func=cmd_config_show) + + # 解析参数 + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(0) + + # 应用全局选项 + global NO_COLOR, JSON_OUTPUT + if args.no_color: + NO_COLOR = True + if args.json_output: + JSON_OUTPUT = True + + # 加载配置 + config = Config() + + # 检查配置(setup 和 config 命令不需要检查) + if args.command not in ("setup", "config"): + if not config.check_warning(): + sys.exit(1) + + # 创建客户端 + client = ApiClient(config) + + # 执行子命令 + if hasattr(args, "func"): + try: + args.func(client, args) + except KeyboardInterrupt: + print("\n\033[33m操作已取消\033[0m") + sys.exit(130) + except Exception as e: + print_error(f"执行出错: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + else: + parser.print_help() + + +if __name__ == "__main__": + main()