chore: 初始化 123yunpan skill 仓库 (v1.3.0)
- 完整 skill 本体:SKILL.md / README.md / manifest.json / config.json - scripts/123yunpan.py:零依赖 CLI(含空间双配额、QPS 限流、零依赖 XLSX) - references/ 参考资料 - 排除 .workbuddy/ 开发目录与 __pycache__
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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 刷新
|
||||
Reference in New Issue
Block a user