bdpan-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdpan_cli/__init__.py +15 -0
- bdpan_cli/_vendor/__init__.py +9 -0
- bdpan_cli/_vendor/openapi_client/__init__.py +27 -0
- bdpan_cli/_vendor/openapi_client/api/__init__.py +6 -0
- bdpan_cli/_vendor/openapi_client/api/auth_api.py +649 -0
- bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py +737 -0
- bdpan_cli/_vendor/openapi_client/api/filemanager_api.py +669 -0
- bdpan_cli/_vendor/openapi_client/api/fileupload_api.py +599 -0
- bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py +391 -0
- bdpan_cli/_vendor/openapi_client/api/userinfo_api.py +315 -0
- bdpan_cli/_vendor/openapi_client/api_client.py +864 -0
- bdpan_cli/_vendor/openapi_client/configuration.py +454 -0
- bdpan_cli/_vendor/openapi_client/exceptions.py +197 -0
- bdpan_cli/_vendor/openapi_client/model/__init__.py +5 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/quotaresponse.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/uinforesponse.py +284 -0
- bdpan_cli/_vendor/openapi_client/model_utils.py +2039 -0
- bdpan_cli/_vendor/openapi_client/rest.py +385 -0
- bdpan_cli/_version.py +3 -0
- bdpan_cli/account.py +78 -0
- bdpan_cli/apply_scrape_rename.py +99 -0
- bdpan_cli/auth.py +218 -0
- bdpan_cli/cli.py +1428 -0
- bdpan_cli/config.py +26 -0
- bdpan_cli/dedup.py +310 -0
- bdpan_cli/downloader.py +178 -0
- bdpan_cli/file_manager.py +389 -0
- bdpan_cli/foldering.py +181 -0
- bdpan_cli/logger.py +101 -0
- bdpan_cli/mcp_server.py +547 -0
- bdpan_cli/media_info.py +251 -0
- bdpan_cli/ops.py +173 -0
- bdpan_cli/organize.py +108 -0
- bdpan_cli/preview_scrape.py +182 -0
- bdpan_cli/scraper.py +505 -0
- bdpan_cli/uploader.py +391 -0
- bdpan_cli-0.1.0.dist-info/METADATA +167 -0
- bdpan_cli-0.1.0.dist-info/RECORD +44 -0
- bdpan_cli-0.1.0.dist-info/WHEEL +4 -0
- bdpan_cli-0.1.0.dist-info/entry_points.txt +5 -0
bdpan_cli/uploader.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""文件上传客户端:预上传 → 分片上传 → 创建文件,支持断点续传。
|
|
2
|
+
|
|
3
|
+
三阶段规格依据官方文档(更新时间以 openapi 文档站为准):
|
|
4
|
+
- 预上传 precreate:autoinit 固定为 1,block_list 为各分片 MD5(32 位小写)
|
|
5
|
+
JSON 数组;响应 block_list 为**需要上传的分片序号列表**(从 0 起)。
|
|
6
|
+
- 分片上传 superfile2:type 固定为 "tmpfile",partseq 从 0 起;非末片必须
|
|
7
|
+
足 4MB(errno=31299),普通用户单文件上限 4GB。
|
|
8
|
+
- 创建文件 create:path/size/isdir/block_list/uploadid/rtype 须与预上传
|
|
9
|
+
完全一致;rtype 0 冲突失败 / 1 冲突重命名 / 3 覆盖。
|
|
10
|
+
|
|
11
|
+
断点续传说明(2026-10-07 真网盘实测):服务端**不按路径跨任务记忆分片**,
|
|
12
|
+
换新 uploadid 重新预上传后缺片列表仍为全量;分片记忆绑定在 uploadid 上。
|
|
13
|
+
故续传采用本地状态文件(记录 uploadid + 已成功分片),恢复时凭原 uploadid
|
|
14
|
+
仅补传缺失分片;uploadid 失效或服务端校验不过时自动回退全新上传。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
from bdpan_cli._vendor import openapi_client
|
|
30
|
+
from bdpan_cli._vendor.openapi_client.api.fileupload_api import FileuploadApi
|
|
31
|
+
from bdpan_cli._vendor.openapi_client.rest import ApiException
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
CHUNK_SIZE = 4 * 1024 * 1024 # 官方分片基准 4MB(普通用户非末片必须足 4MB)
|
|
36
|
+
_CHUNK_RETRY = 3 # 分片上传瞬时失败重试次数
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UploadError(RuntimeError):
|
|
40
|
+
"""上传流程异常。"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NamedBytesIO(io.BytesIO):
|
|
44
|
+
"""SDK 多部件序列化要求文件对象带 .name 属性(取文件名与 MIME 推断)。"""
|
|
45
|
+
|
|
46
|
+
name = "blob"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Uploader:
|
|
50
|
+
"""网盘文件上传封装(三阶段,本地状态文件断点续传)。"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, access_token: str, api_client: openapi_client.ApiClient | None = None) -> None:
|
|
53
|
+
self.access_token = access_token
|
|
54
|
+
self.api_client = api_client or openapi_client.ApiClient()
|
|
55
|
+
self._fileupload_api = FileuploadApi(self.api_client)
|
|
56
|
+
|
|
57
|
+
def locate_upload_host(self) -> str:
|
|
58
|
+
"""动态探测最优机房上传 host,失败时回退至默认 d.pcs.baidu.com。"""
|
|
59
|
+
url = f"https://d.pcs.baidu.com/rest/2.0/pcs/file?method=locateupload&access_token={self.access_token}"
|
|
60
|
+
try:
|
|
61
|
+
req = urllib.request.Request(url, headers={"User-Agent": "pan.baidu.com"})
|
|
62
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
63
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
64
|
+
servers = data.get("servers") or []
|
|
65
|
+
if servers and isinstance(servers, list):
|
|
66
|
+
host = servers[0].get("host")
|
|
67
|
+
if host:
|
|
68
|
+
return str(host)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.debug("动态机房探测失败,使用默认 host: %s", e)
|
|
71
|
+
return "d.pcs.baidu.com"
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# 分片规划与哈希
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _plan_chunks(size: int, chunk_size: int = CHUNK_SIZE) -> list[tuple[int, int]]:
|
|
79
|
+
"""规划分片: 返回 (offset, length) 列表;≤chunk_size 单片。"""
|
|
80
|
+
if size <= chunk_size:
|
|
81
|
+
return [(0, size)]
|
|
82
|
+
return [(i, min(chunk_size, size - i)) for i in range(0, size, chunk_size)]
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _hash_file(local_path: str, chunks: list[tuple[int, int]]) -> list[str]:
|
|
86
|
+
"""逐片流式计算内容 MD5(32 位小写),不整读大文件进内存。"""
|
|
87
|
+
md5s: list[str] = []
|
|
88
|
+
with open(local_path, "rb") as f:
|
|
89
|
+
for offset, length in chunks:
|
|
90
|
+
f.seek(offset)
|
|
91
|
+
h = hashlib.md5()
|
|
92
|
+
remaining = length
|
|
93
|
+
while remaining > 0:
|
|
94
|
+
buf = f.read(min(1024 * 1024, remaining))
|
|
95
|
+
if not buf:
|
|
96
|
+
raise UploadError(f"本地文件读取提前结束 [{local_path}] @{offset}")
|
|
97
|
+
h.update(buf)
|
|
98
|
+
remaining -= len(buf)
|
|
99
|
+
md5s.append(h.hexdigest())
|
|
100
|
+
return md5s
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# 三阶段流程
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def upload(
|
|
107
|
+
self,
|
|
108
|
+
local_path: str,
|
|
109
|
+
remote_path: str,
|
|
110
|
+
rtype: int = 1,
|
|
111
|
+
progress: Callable[[dict[str, Any]], None] | None = None,
|
|
112
|
+
state_file: str | Path | None = None,
|
|
113
|
+
local_key: str | None = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""上传本地文件到网盘指定路径。
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
local_path: 本地文件路径(必须为已存在文件)。
|
|
119
|
+
remote_path: 网盘目标文件绝对路径。
|
|
120
|
+
rtype: 冲突策略,与预上传/创建两阶段保持一致
|
|
121
|
+
(1 冲突重命名,3 覆盖)。
|
|
122
|
+
progress: 进度回调,接收 phase=precreate/resume/chunk/create 事件。
|
|
123
|
+
state_file: 断点续传状态文件路径;None 为不持久化(单次完成,
|
|
124
|
+
中断即从头再来)。
|
|
125
|
+
local_key: 状态键的本地侧标识(默认 local_path),用于同一本地
|
|
126
|
+
文件分批传到多个远端路径时区分任务。
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
创建文件阶段的响应(含 path/size/fs_id 等)。
|
|
130
|
+
"""
|
|
131
|
+
if not os.path.isfile(local_path):
|
|
132
|
+
raise UploadError(f"本地文件不存在 [{local_path}]")
|
|
133
|
+
|
|
134
|
+
key = f"{local_key or local_path}->{remote_path}"
|
|
135
|
+
size = os.path.getsize(local_path)
|
|
136
|
+
chunks = self._plan_chunks(size)
|
|
137
|
+
md5s = self._hash_file(local_path, chunks)
|
|
138
|
+
|
|
139
|
+
state = self._load_state(state_file, key) if state_file else None
|
|
140
|
+
if state and state.get("size") == size and state.get("md5s") == md5s:
|
|
141
|
+
try:
|
|
142
|
+
res = self._upload_resume(
|
|
143
|
+
state, chunks, local_path, remote_path, rtype, md5s, progress
|
|
144
|
+
)
|
|
145
|
+
self._clear_state(state_file, key)
|
|
146
|
+
return res
|
|
147
|
+
except UploadError as e:
|
|
148
|
+
logger.info("续传任务失效,回退全新上传: %s", e)
|
|
149
|
+
self._clear_state(state_file, key)
|
|
150
|
+
|
|
151
|
+
return self._upload_fresh(
|
|
152
|
+
local_path, remote_path, size, chunks, md5s, rtype, progress, state_file, key
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _upload_resume(
|
|
156
|
+
self,
|
|
157
|
+
state: dict[str, Any],
|
|
158
|
+
chunks: list[tuple[int, int]],
|
|
159
|
+
local_path: str,
|
|
160
|
+
remote_path: str,
|
|
161
|
+
rtype: int,
|
|
162
|
+
md5s: list[str],
|
|
163
|
+
progress: Callable[[dict[str, Any]], None] | None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""凭本地状态(原 uploadid + 已传分片)续传。"""
|
|
166
|
+
uploadid = str(state["uploadid"])
|
|
167
|
+
uploaded: set[int] = set(state.get("uploaded") or [])
|
|
168
|
+
missing = [i for i in range(len(chunks)) if i not in uploaded]
|
|
169
|
+
if progress:
|
|
170
|
+
progress({"phase": "resume", "present": len(uploaded), "missing": len(missing)})
|
|
171
|
+
for seq in missing:
|
|
172
|
+
self._upload_chunk(uploadid, remote_path, seq, chunks, local_path, progress)
|
|
173
|
+
return self._create(remote_path, int(state["size"]), md5s, uploadid, rtype, progress)
|
|
174
|
+
|
|
175
|
+
def _upload_fresh(
|
|
176
|
+
self,
|
|
177
|
+
local_path: str,
|
|
178
|
+
remote_path: str,
|
|
179
|
+
size: int,
|
|
180
|
+
chunks: list[tuple[int, int]],
|
|
181
|
+
md5s: list[str],
|
|
182
|
+
rtype: int,
|
|
183
|
+
progress: Callable[[dict[str, Any]], None] | None,
|
|
184
|
+
state_file: str | Path | None,
|
|
185
|
+
key: str,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""全新三阶段上传;状态文件存在时边传边记账。"""
|
|
188
|
+
pre = self._precreate(remote_path, size, md5s, rtype)
|
|
189
|
+
if progress:
|
|
190
|
+
progress({"phase": "precreate"})
|
|
191
|
+
uploadid = str(pre["uploadid"])
|
|
192
|
+
|
|
193
|
+
# 极速秒传判定:云端命中相同特征文件,无需上传任何分片直接创建
|
|
194
|
+
if pre.get("return_type") == 2:
|
|
195
|
+
if progress:
|
|
196
|
+
progress({"phase": "fast_upload", "uploadid": uploadid})
|
|
197
|
+
res = self._create(remote_path, size, md5s, uploadid, rtype, progress)
|
|
198
|
+
self._clear_state(state_file, key)
|
|
199
|
+
return res
|
|
200
|
+
|
|
201
|
+
missing = [int(x) for x in pre.get("block_list") or []]
|
|
202
|
+
uploaded = [i for i in range(len(chunks)) if i not in missing]
|
|
203
|
+
self._save_state(
|
|
204
|
+
state_file, key, local_path, remote_path, size, uploadid, md5s, uploaded
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
for seq in missing:
|
|
208
|
+
self._upload_chunk(uploadid, remote_path, seq, chunks, local_path, progress)
|
|
209
|
+
uploaded.append(seq)
|
|
210
|
+
self._save_state(
|
|
211
|
+
state_file, key, local_path, remote_path, size, uploadid, md5s, uploaded
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
res = self._create(remote_path, size, md5s, uploadid, rtype, progress)
|
|
215
|
+
self._clear_state(state_file, key)
|
|
216
|
+
return res
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# 状态文件(断点续传账本)
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _load_state(state_file: str | Path | None, key: str) -> dict[str, Any] | None:
|
|
224
|
+
if not state_file:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
with open(state_file, encoding="utf-8") as f:
|
|
228
|
+
book = json.load(f)
|
|
229
|
+
return book.get(key) if isinstance(book, dict) else None
|
|
230
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
231
|
+
logger.warning("续传状态文件不可读,按全新上传处理 [%s]: %s", state_file, e)
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _save_state(
|
|
236
|
+
state_file: str | Path | None,
|
|
237
|
+
key: str,
|
|
238
|
+
local_path: str,
|
|
239
|
+
remote_path: str,
|
|
240
|
+
size: int,
|
|
241
|
+
uploadid: str,
|
|
242
|
+
md5s: list[str],
|
|
243
|
+
uploaded: list[int],
|
|
244
|
+
) -> None:
|
|
245
|
+
if not state_file:
|
|
246
|
+
return
|
|
247
|
+
book: dict[str, Any] = {}
|
|
248
|
+
try:
|
|
249
|
+
with open(state_file, encoding="utf-8") as f:
|
|
250
|
+
loaded = json.load(f)
|
|
251
|
+
if isinstance(loaded, dict):
|
|
252
|
+
book = loaded
|
|
253
|
+
except (OSError, json.JSONDecodeError):
|
|
254
|
+
pass
|
|
255
|
+
book[key] = {
|
|
256
|
+
"local_path": local_path,
|
|
257
|
+
"remote_path": remote_path,
|
|
258
|
+
"size": size,
|
|
259
|
+
"uploadid": uploadid,
|
|
260
|
+
"md5s": md5s,
|
|
261
|
+
"uploaded": uploaded,
|
|
262
|
+
"updated_at": int(time.time()),
|
|
263
|
+
}
|
|
264
|
+
with open(state_file, "w", encoding="utf-8") as f:
|
|
265
|
+
json.dump(book, f, ensure_ascii=False, indent=2)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _clear_state(state_file: str | Path | None, key: str) -> None:
|
|
269
|
+
if not state_file:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
with open(state_file, encoding="utf-8") as f:
|
|
273
|
+
book = json.load(f)
|
|
274
|
+
if isinstance(book, dict) and book.pop(key, None) is not None:
|
|
275
|
+
if book:
|
|
276
|
+
with open(state_file, "w", encoding="utf-8") as f:
|
|
277
|
+
json.dump(book, f, ensure_ascii=False, indent=2)
|
|
278
|
+
else:
|
|
279
|
+
os.unlink(state_file)
|
|
280
|
+
except (OSError, json.JSONDecodeError):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# 三阶段原语
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _precreate(
|
|
288
|
+
self,
|
|
289
|
+
remote_path: str,
|
|
290
|
+
size: int,
|
|
291
|
+
md5s: list[str],
|
|
292
|
+
rtype: int,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
block_list = json.dumps(md5s, separators=(",", ":"))
|
|
295
|
+
try:
|
|
296
|
+
res = self._fileupload_api.xpanfileprecreate(
|
|
297
|
+
access_token=self.access_token,
|
|
298
|
+
path=remote_path,
|
|
299
|
+
isdir=0,
|
|
300
|
+
size=size,
|
|
301
|
+
autoinit=1,
|
|
302
|
+
block_list=block_list,
|
|
303
|
+
rtype=rtype,
|
|
304
|
+
)
|
|
305
|
+
except ApiException as e:
|
|
306
|
+
raise UploadError(f"预上传失败 [{remote_path}]: {e.status} {e.body}") from e
|
|
307
|
+
return self._check(res, f"预上传返回错误 [{remote_path}]")
|
|
308
|
+
|
|
309
|
+
def _upload_chunk(
|
|
310
|
+
self,
|
|
311
|
+
uploadid: str,
|
|
312
|
+
remote_path: str,
|
|
313
|
+
seq: int,
|
|
314
|
+
chunks: list[tuple[int, int]],
|
|
315
|
+
local_path: str,
|
|
316
|
+
progress: Callable[[dict[str, Any]], None] | None,
|
|
317
|
+
) -> None:
|
|
318
|
+
offset, length = chunks[seq]
|
|
319
|
+
with open(local_path, "rb") as f:
|
|
320
|
+
f.seek(offset)
|
|
321
|
+
data = f.read(length)
|
|
322
|
+
if len(data) != length:
|
|
323
|
+
raise UploadError(f"本地文件读取提前结束 [{local_path}] @{offset}")
|
|
324
|
+
|
|
325
|
+
last_error: Exception | None = None
|
|
326
|
+
for attempt in range(1, _CHUNK_RETRY + 1):
|
|
327
|
+
try:
|
|
328
|
+
# SDK 的 file 参数要求带 .name 的 IOBase 文件对象
|
|
329
|
+
res = self._fileupload_api.pcssuperfile2(
|
|
330
|
+
access_token=self.access_token,
|
|
331
|
+
partseq=str(seq),
|
|
332
|
+
path=remote_path,
|
|
333
|
+
uploadid=uploadid,
|
|
334
|
+
type="tmpfile",
|
|
335
|
+
file=_NamedBytesIO(data),
|
|
336
|
+
)
|
|
337
|
+
self._check_chunk(res, f"分片上传返回错误 [{remote_path} #{seq}]")
|
|
338
|
+
if progress:
|
|
339
|
+
progress({"phase": "chunk", "seq": seq, "total": len(chunks)})
|
|
340
|
+
return
|
|
341
|
+
except (ApiException, UploadError) as e:
|
|
342
|
+
last_error = e
|
|
343
|
+
logger.warning("分片 %d 上传第 %d 次尝试失败: %s", seq, attempt, e)
|
|
344
|
+
if attempt < _CHUNK_RETRY:
|
|
345
|
+
time.sleep(1)
|
|
346
|
+
raise UploadError(f"分片上传失败 [{remote_path} #{seq}] 重试 {_CHUNK_RETRY} 次后放弃: {last_error}") from last_error
|
|
347
|
+
|
|
348
|
+
def _create(
|
|
349
|
+
self,
|
|
350
|
+
remote_path: str,
|
|
351
|
+
size: int,
|
|
352
|
+
md5s: list[str],
|
|
353
|
+
uploadid: str,
|
|
354
|
+
rtype: int,
|
|
355
|
+
progress: Callable[[dict[str, Any]], None] | None,
|
|
356
|
+
) -> dict[str, Any]:
|
|
357
|
+
block_list = json.dumps(md5s, separators=(",", ":"))
|
|
358
|
+
try:
|
|
359
|
+
res = self._fileupload_api.xpanfilecreate(
|
|
360
|
+
access_token=self.access_token,
|
|
361
|
+
path=remote_path,
|
|
362
|
+
isdir=0,
|
|
363
|
+
size=size,
|
|
364
|
+
uploadid=uploadid,
|
|
365
|
+
block_list=block_list,
|
|
366
|
+
rtype=rtype,
|
|
367
|
+
)
|
|
368
|
+
except ApiException as e:
|
|
369
|
+
raise UploadError(f"创建文件失败 [{remote_path}]: {e.status} {e.body}") from e
|
|
370
|
+
if progress:
|
|
371
|
+
progress({"phase": "create"})
|
|
372
|
+
return self._check(res, f"创建文件返回错误 [{remote_path}]")
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _check(res: Any, ctx: str) -> dict[str, Any]:
|
|
376
|
+
if not isinstance(res, dict) or res.get("errno") != 0:
|
|
377
|
+
detail = res if isinstance(res, dict) else repr(res)
|
|
378
|
+
errno = res.get("errno") if isinstance(res, dict) else "?"
|
|
379
|
+
raise UploadError(f"{ctx}: errno={errno} ({detail})")
|
|
380
|
+
return res
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _check_chunk(res: Any, ctx: str) -> dict[str, Any]:
|
|
384
|
+
"""分片上传响应校验。
|
|
385
|
+
|
|
386
|
+
官方规格:成功响应仅含 md5/request_id,errno 字段失败时才返回,
|
|
387
|
+
故"无 errno"视作成功(区别于预上传/创建恒带 errno 的形态)。
|
|
388
|
+
"""
|
|
389
|
+
if isinstance(res, dict) and res.get("errno") not in (0, None):
|
|
390
|
+
raise UploadError(f"{ctx}: errno={res.get('errno')} ({res})")
|
|
391
|
+
return res
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: bdpan-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 百度网盘工程化 CLI 管理工具
|
|
5
|
+
Keywords: baidu-pan,netdisk,cli,scraper,backup,cloud-storage
|
|
6
|
+
Author: CodingRookie98
|
|
7
|
+
Author-email: CodingRookie98 <520whjoker@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: System :: Archiving
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Dist: python-dotenv>=1.2.3
|
|
21
|
+
Requires-Dist: urllib3>=1.25.3
|
|
22
|
+
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Project-URL: Homepage, https://github.com/CodingRookie98/bdpan-cli
|
|
25
|
+
Project-URL: Repository, https://github.com/CodingRookie98/bdpan-cli
|
|
26
|
+
Project-URL: Documentation, https://github.com/CodingRookie98/bdpan-cli#readme
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# bdpan-cli
|
|
30
|
+
|
|
31
|
+
[](https://opensource.org/licenses/MIT)
|
|
32
|
+
[](https://www.python.org/downloads/)
|
|
33
|
+
[](https://github.com/astral-sh/uv)
|
|
34
|
+
|
|
35
|
+
[中文](README.md) | [English](README_en.md)
|
|
36
|
+
|
|
37
|
+
百度网盘工程化 CLI 管理工具。基于[百度网盘开放平台](https://pan.baidu.com/union/doc/)官方 SDK 与协议标准构建,提供文件管理、分片上传续传、冲突副本治理与影视媒体库规范化自动化刮削整理。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 快速安装 (Installation)
|
|
42
|
+
|
|
43
|
+
### 方式 1:uv tool 安装(推荐,隔离环境)
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install bdpan-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 方式 2:即用即走(无需预先安装)
|
|
49
|
+
```bash
|
|
50
|
+
uvx bdpan-cli --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 方式 3:传统 pip 安装
|
|
54
|
+
```bash
|
|
55
|
+
pip install bdpan-cli
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 方式 4:跨平台单文件二进制(免 Python 运行时)
|
|
59
|
+
前往 [GitHub Releases](https://github.com/CodingRookie98/bdpan-cli/releases) 直接下载对应平台单文件二进制可执行包,加入 `PATH` 即可运行:
|
|
60
|
+
- `bdpan-linux-amd64`
|
|
61
|
+
- `bdpan-linux-arm64`
|
|
62
|
+
- `bdpan-macos-arm64` (Apple Silicon)
|
|
63
|
+
- `bdpan-macos-x86_64` (Intel)
|
|
64
|
+
- `bdpan-windows-amd64.exe`
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 命令行使用 (CLI)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 查看帮助
|
|
72
|
+
bdpan --help
|
|
73
|
+
|
|
74
|
+
# 常用命令
|
|
75
|
+
bdpan ls /影视/电影
|
|
76
|
+
bdpan mkdir /新目录
|
|
77
|
+
bdpan mv <源路径>... <目标目录> # 多源时目标须为已存在目录(自动记录流水)
|
|
78
|
+
bdpan cp <源路径>... <目标目录|目标目录/新名> # 单源支持改名
|
|
79
|
+
bdpan rename <路径> <新名称> # 原地更名(自动记录流水)
|
|
80
|
+
bdpan rm <路径>... [-y]
|
|
81
|
+
bdpan quota [--check-expire] [--check-free] # 网盘容量
|
|
82
|
+
bdpan whoami # 账户信息
|
|
83
|
+
bdpan search <关键词> [--dir 目录] [--no-recursion]
|
|
84
|
+
bdpan tree <路径> [--depth N] [--index 索引.json] # 递归树+本地索引
|
|
85
|
+
bdpan upload <本地文件> <目标目录|目标目录/新名> [--overwrite] # 分片上传+断点续传(首片秒传+动态机房)
|
|
86
|
+
bdpan download <网盘文件>... [--dest 本地目录] [--overwrite] # 大文件断点续传下载
|
|
87
|
+
bdpan dlink <网盘文件>... [--aria2 | --curl] # 提取高速直链或导出下载脚本
|
|
88
|
+
bdpan dedup /影视/电影 --staging-dir /隔离区 # 治理 (1)/(2) 冲突副本
|
|
89
|
+
bdpan scrape foldering /影视/电影/unscraped # 媒体库电影散落文件建目录归集(默认方案 A,支持 --format 模板)
|
|
90
|
+
bdpan scrape preview /影视/电影/待刮削 # 刮削重命名预演(支持 --format 模板,生成对照表)
|
|
91
|
+
bdpan scrape apply preview.csv # 批量应用刮削重命名
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# 安全运维体系与操作撤销 (Safety & Undo)
|
|
95
|
+
bdpan ops list [--limit 20] # 查看关键变更操作流水审计日志
|
|
96
|
+
bdpan ops show <op_id> # 查看某次操作的明细与映射清单
|
|
97
|
+
bdpan undo <op_id> [--dry-run] [-y] # 逆向撤销回滚指定批次操作(支持预演)
|
|
98
|
+
|
|
99
|
+
# 多账号认证与隔离 (Multi-Account Auth)
|
|
100
|
+
bdpan auth list # 查看本地保存的网盘账号及凭证有效性
|
|
101
|
+
bdpan auth switch <alias> # 切换当前默认活跃账号
|
|
102
|
+
bdpan auth login [--account <alias>] # 登录/重新授权指定账号别名
|
|
103
|
+
bdpan --account <alias> <subcommand> # 临时以指定账号执行单次命令(多租户并发隔离)
|
|
104
|
+
|
|
105
|
+
# 媒体可观测性与全局选项 (Media Insights & Global Options)
|
|
106
|
+
bdpan info /影视/电影/Inception.mkv # 探测视频精准分辨率(4K/1080P)、时长、HEVC编码与音轨
|
|
107
|
+
bdpan stats /影视/电影 [--depth N] # 递归聚合统计目录总容量、总文件数及媒体格式分类占比
|
|
108
|
+
bdpan -V / --version # 查看当前 CLI 工具版本号
|
|
109
|
+
bdpan --json <subcommand> # 所有核心查询命令支持标准化 JSON 信封输出(脚本化集成)
|
|
110
|
+
bdpan --debug <subcommand> # 开启详细诊断调试日志(输出至 stderr,双写至 ~/.config/bdpan/logs/)
|
|
111
|
+
bdpan -q <subcommand> # 静音非错误输出(仅保留 ERROR)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# AI 原生集成 (Model Context Protocol / MCP)
|
|
115
|
+
bdpan mcp # 启动标准 stdio MCP Server,供 Claude Code / Cursor / AI 智能体调用
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **操作安全机制**:`mv` / `cp` 遵循严格目标判定;`rm` 默认交互确认,`-y` 跳过;网盘删除由服务端移入回收站。批量操作支持 `--dry-run` 预演;所有破坏性移动更名自动落盘流水审计,支持一键逆向回滚还原。
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 本地开发环境
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 克隆仓库
|
|
126
|
+
git clone https://github.com/CodingRookie98/bdpan-cli.git
|
|
127
|
+
cd bdpan-cli
|
|
128
|
+
|
|
129
|
+
# 安装依赖
|
|
130
|
+
uv sync
|
|
131
|
+
|
|
132
|
+
# 本地调试运行
|
|
133
|
+
uv run bdpan --help
|
|
134
|
+
|
|
135
|
+
# 运行全量测试
|
|
136
|
+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 uv run pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 应用凭证与用户数据
|
|
140
|
+
|
|
141
|
+
1. 开发者应用密钥:复制模板并填入开放平台应用的 AppKey / AppSecret(`.env` 已被 git 忽略,不会提交):
|
|
142
|
+
```bash
|
|
143
|
+
cp .env.example .env
|
|
144
|
+
```
|
|
145
|
+
2. 用户登录授权凭据:用户首次通过 OAuth 登录授权后,Token 自动保存在 `~/.config/bdpan/tokens.json`(受系统权限 `0o600` 保护)。
|
|
146
|
+
3. 诊断调试日志:完整执行细节自动落盘双写至 `~/.config/bdpan/logs/bdpan.log`。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 项目结构
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
├── docs/ # 治理文档(Diátaxis 四象限,见 docs/GOVERNANCE.md)
|
|
154
|
+
│ ├── index.md # 知识库总入口
|
|
155
|
+
│ ├── llms.txt # AI 智能体机器地图
|
|
156
|
+
│ ├── project/ # 演进路线图 (roadmap.md)、待办 (backlog.md)、计划 (plans/)
|
|
157
|
+
│ └── reference/api/ # SDK 接口参考(机器真理源)
|
|
158
|
+
├── pyproject.toml # 标准 Python 项目配置与 CLI 命令行入口
|
|
159
|
+
├── src/bdpan_cli/ # 核心业务代码与 CLI 子命令
|
|
160
|
+
│ └── _vendor/ # 内聚内嵌的 OpenAPI Client 依赖包(标准轮子直接打包)
|
|
161
|
+
├── tests/ # 单元测试与回归套件 (180+ 项全绿)
|
|
162
|
+
└── .python-version # 3.12
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## 贡献与开源
|
|
166
|
+
|
|
167
|
+
详细贡献规范、测试门禁与提交信息格式见 [CONTRIBUTING.md](./CONTRIBUTING.md)。开源许可协议遵循 [MIT License](./LICENSE)。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
bdpan_cli/__init__.py,sha256=43zVFN3Z9HqGu-GamN_qz97i61laImxOdQJQJofexzs,433
|
|
2
|
+
bdpan_cli/_vendor/__init__.py,sha256=NcVpi0ktLVqOMVf39mSkYOJrkG6-01OWbOPW6sAW6so,255
|
|
3
|
+
bdpan_cli/_vendor/openapi_client/__init__.py,sha256=mp15Wbp-0VGMrvg3kPcmdQeSO10UrvWems9Ggh1urH4,806
|
|
4
|
+
bdpan_cli/_vendor/openapi_client/api/__init__.py,sha256=wfB-C-yN5UCFDbXIJDO2ANz7VnaqdHWSw9mkGK56iE4,253
|
|
5
|
+
bdpan_cli/_vendor/openapi_client/api/auth_api.py,sha256=NU9glyl5Zfe6tkJQ-ft3ID8U9OpKxKpjDnvBnabRoHI,23724
|
|
6
|
+
bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py,sha256=iZ2x3_wNpKTi-szs5xp-BL2KAPDRW6xqu89djuvi1mc,25884
|
|
7
|
+
bdpan_cli/_vendor/openapi_client/api/filemanager_api.py,sha256=lKNCR9Y6gi0xzRtG8Z9Z8wMWKqOHyDrpKxFPbz1ZMto,23633
|
|
8
|
+
bdpan_cli/_vendor/openapi_client/api/fileupload_api.py,sha256=FE34o08M0D7gfUnagY7pZr6r1J8iAE7RmS1gg0ZL9Ck,21449
|
|
9
|
+
bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py,sha256=Le66HLWp4bm_EZ3oSQJ4YOHfFNnFqOPYL3yRS3ETdL0,13717
|
|
10
|
+
bdpan_cli/_vendor/openapi_client/api/userinfo_api.py,sha256=uv_woQ3QDA-Zvb9DyBcYkzY8FJgtcYvJWiF5pDYZMB8,11049
|
|
11
|
+
bdpan_cli/_vendor/openapi_client/api_client.py,sha256=d9H6Q1v_AcclE9kLBdinsB8iMQxFrYCzSiezhYW_InU,37645
|
|
12
|
+
bdpan_cli/_vendor/openapi_client/configuration.py,sha256=krMUom9Zt014nuY0bdQmrZ2KgOb4bNidDsbPRyYzHqE,16450
|
|
13
|
+
bdpan_cli/_vendor/openapi_client/exceptions.py,sha256=KVNRAl1loqB327yecI6O9qLVGCTR08QdV2AcpX3zrL0,5565
|
|
14
|
+
bdpan_cli/_vendor/openapi_client/model/__init__.py,sha256=N49d9K35V_Hd5lOHWcMeVRl0Iy_-L-03rZgfKXwlESM,348
|
|
15
|
+
bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py,sha256=zYDlt4ggc_cNOcjT51Ma_DlF-cUQb6iqhGL6hvpMXMA,12166
|
|
16
|
+
bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py,sha256=MtnlQzxdehg6k5yLnovDHFSi-2T_G8RI3fUpejmNYW8,12140
|
|
17
|
+
bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py,sha256=nacbzBa8CQrJ_C8ECm9USVv5Mjut05Hr3ZX5h9YEX90,12148
|
|
18
|
+
bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py,sha256=C8r0JWAnPGwsAz2PO13M5W86OltuGWhp3Zbklj0MghI,12151
|
|
19
|
+
bdpan_cli/_vendor/openapi_client/model/quotaresponse.py,sha256=zDos_i5lmzv6m3v29PE1fUJyg_qm4z0JoZcVkfEMtYY,11948
|
|
20
|
+
bdpan_cli/_vendor/openapi_client/model/uinforesponse.py,sha256=rzYlQH6o6JvJgc0x1iM57K-uUqNDrq5XYGM8zone3Y0,12406
|
|
21
|
+
bdpan_cli/_vendor/openapi_client/model_utils.py,sha256=eVnKEkzJvMKz7zHZV8gyYml5Vpx9KpGi3k9WAifNtgc,81921
|
|
22
|
+
bdpan_cli/_vendor/openapi_client/rest.py,sha256=c2uweRsBDAQT5W2ZjviVclNK1r3AYjPkpFDwA1Zi5Fg,14799
|
|
23
|
+
bdpan_cli/_version.py,sha256=YyZPgBPtX1TTvnWa3bqs1EWFiGX0OUBZTSh6_vaPx2A,63
|
|
24
|
+
bdpan_cli/account.py,sha256=Ex7L7DRZtZEG70nqiJHoDCzc843ODM_pcj7NLIo8xS8,3103
|
|
25
|
+
bdpan_cli/apply_scrape_rename.py,sha256=MjHPDIfKuxslkVa7QFhz_7sfLJTkx51zrkklpnrLrgo,3821
|
|
26
|
+
bdpan_cli/auth.py,sha256=-OgGKIKGPxExQOdTIfNsMnwo7xOjI41joiIZBhs0pRg,8875
|
|
27
|
+
bdpan_cli/cli.py,sha256=Z3x0oXWZAOrY3ZLzP_IdHMC8Up9EgBYMYmH3nBeLCGs,60653
|
|
28
|
+
bdpan_cli/config.py,sha256=iCOFZ-fFDICHiKc5Cq2toefZc6idL1kqgfZOKys-R5k,736
|
|
29
|
+
bdpan_cli/dedup.py,sha256=Eix5ALoY-AIdxkfBiiLM0YYL53BiFnOzammvTlP-Fgw,10982
|
|
30
|
+
bdpan_cli/downloader.py,sha256=mPb-FOnYMoSKwFfLYIp-2iYri1Jzb38ASat4g9M3tV0,6981
|
|
31
|
+
bdpan_cli/file_manager.py,sha256=1qb6qWg73vHvYSladiurKWKkwWlxBLCGV5Yep-MhSc8,15171
|
|
32
|
+
bdpan_cli/foldering.py,sha256=pgYVbKbhfu6Q-Y0EVlZIvv_iCkGdVmQh26TqnwGVmh0,6368
|
|
33
|
+
bdpan_cli/logger.py,sha256=jXZMgQ1aW4YG1N4aQiYzvGXgV8x4VeoqJFn0DAVdQL0,3663
|
|
34
|
+
bdpan_cli/mcp_server.py,sha256=cwO2Ipq3HE74--5HQGTaBOYjSE_pmzqGVbCtv3yx3wo,25086
|
|
35
|
+
bdpan_cli/media_info.py,sha256=1_cfJfXq_KxtWLFkAMS9SnmON_WUCq8bFyiL2g3dZeM,8604
|
|
36
|
+
bdpan_cli/ops.py,sha256=bPOB6xAgmCNr4NsT48236Lc-wdCDN6ZA_dvjdvKNaiE,6454
|
|
37
|
+
bdpan_cli/organize.py,sha256=U-5QtVm4nvI3dY9K84oy0rVMRIXL1mkmQ2pZktKv76o,4357
|
|
38
|
+
bdpan_cli/preview_scrape.py,sha256=FSFgiZZW5p4jhTPS_Z1aKcJeuxTAVZUtMxPeJ2FMeT0,6422
|
|
39
|
+
bdpan_cli/scraper.py,sha256=AVklD7_zZjop0b6CZhK660bFsal2IyZ36qYX3Ch_2f8,20089
|
|
40
|
+
bdpan_cli/uploader.py,sha256=usJJxTMrtmJyUzbktBjKfIoX_bZImx9GF5qCgkd_rc0,15739
|
|
41
|
+
bdpan_cli-0.1.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
42
|
+
bdpan_cli-0.1.0.dist-info/entry_points.txt,sha256=2lUDk2LxVidWUk1OKKrrwA0g9n2kQBSnLexFIvmg508,114
|
|
43
|
+
bdpan_cli-0.1.0.dist-info/METADATA,sha256=2apyne_uaMQhBGt7c56dlXIN31GxxY2wyxZzCZF0nsg,7605
|
|
44
|
+
bdpan_cli-0.1.0.dist-info/RECORD,,
|