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.
Files changed (44) hide show
  1. bdpan_cli/__init__.py +15 -0
  2. bdpan_cli/_vendor/__init__.py +9 -0
  3. bdpan_cli/_vendor/openapi_client/__init__.py +27 -0
  4. bdpan_cli/_vendor/openapi_client/api/__init__.py +6 -0
  5. bdpan_cli/_vendor/openapi_client/api/auth_api.py +649 -0
  6. bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py +737 -0
  7. bdpan_cli/_vendor/openapi_client/api/filemanager_api.py +669 -0
  8. bdpan_cli/_vendor/openapi_client/api/fileupload_api.py +599 -0
  9. bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py +391 -0
  10. bdpan_cli/_vendor/openapi_client/api/userinfo_api.py +315 -0
  11. bdpan_cli/_vendor/openapi_client/api_client.py +864 -0
  12. bdpan_cli/_vendor/openapi_client/configuration.py +454 -0
  13. bdpan_cli/_vendor/openapi_client/exceptions.py +197 -0
  14. bdpan_cli/_vendor/openapi_client/model/__init__.py +5 -0
  15. bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py +276 -0
  16. bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py +276 -0
  17. bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py +276 -0
  18. bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py +276 -0
  19. bdpan_cli/_vendor/openapi_client/model/quotaresponse.py +276 -0
  20. bdpan_cli/_vendor/openapi_client/model/uinforesponse.py +284 -0
  21. bdpan_cli/_vendor/openapi_client/model_utils.py +2039 -0
  22. bdpan_cli/_vendor/openapi_client/rest.py +385 -0
  23. bdpan_cli/_version.py +3 -0
  24. bdpan_cli/account.py +78 -0
  25. bdpan_cli/apply_scrape_rename.py +99 -0
  26. bdpan_cli/auth.py +218 -0
  27. bdpan_cli/cli.py +1428 -0
  28. bdpan_cli/config.py +26 -0
  29. bdpan_cli/dedup.py +310 -0
  30. bdpan_cli/downloader.py +178 -0
  31. bdpan_cli/file_manager.py +389 -0
  32. bdpan_cli/foldering.py +181 -0
  33. bdpan_cli/logger.py +101 -0
  34. bdpan_cli/mcp_server.py +547 -0
  35. bdpan_cli/media_info.py +251 -0
  36. bdpan_cli/ops.py +173 -0
  37. bdpan_cli/organize.py +108 -0
  38. bdpan_cli/preview_scrape.py +182 -0
  39. bdpan_cli/scraper.py +505 -0
  40. bdpan_cli/uploader.py +391 -0
  41. bdpan_cli-0.1.0.dist-info/METADATA +167 -0
  42. bdpan_cli-0.1.0.dist-info/RECORD +44 -0
  43. bdpan_cli-0.1.0.dist-info/WHEEL +4 -0
  44. bdpan_cli-0.1.0.dist-info/entry_points.txt +5 -0
bdpan_cli/config.py ADDED
@@ -0,0 +1,26 @@
1
+ """应用配置:从 .env 读取百度网盘开放平台凭证。"""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from dotenv import load_dotenv
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AppConfig:
11
+ app_key: str
12
+ secret_key: str
13
+
14
+
15
+ def load_config() -> AppConfig:
16
+ load_dotenv()
17
+ values = {
18
+ "BAIDU_APP_KEY": os.getenv("BAIDU_APP_KEY", "").strip(),
19
+ "BAIDU_SECRET_KEY": os.getenv("BAIDU_SECRET_KEY", "").strip(),
20
+ }
21
+ missing = [name for name, value in values.items() if not value]
22
+ if missing:
23
+ raise RuntimeError(
24
+ f"缺少配置项:{'、'.join(missing)},请在 .env 中填写(参考 .env.example)"
25
+ )
26
+ return AppConfig(values["BAIDU_APP_KEY"], values["BAIDU_SECRET_KEY"])
bdpan_cli/dedup.py ADDED
@@ -0,0 +1,310 @@
1
+ """网盘冲突副本与重复文件扫描与治理模块 (BK-0027)。
2
+
3
+ 支持检测并治理百度网盘因重复上传或同步产生的形如 `(1)`、`(2)` 的冲突副本、
4
+ 无主编号副本以及空目录,提供安全隔离(--staging-dir)与回收站清理(--delete)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ from collections import defaultdict
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from bdpan_cli.file_manager import FileManager, FileManagerError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # 匹配冲突副本后缀,如 "name (1)", "name(2)", "name (01).mp4", "name (1).mkv"
21
+ # index 限制为 1-3 位数字,自动排除 4 位年份数字如 (1995), (2021)
22
+ CONFLICT_SUFFIX_PATTERN = re.compile(
23
+ r"^(?P<base>.+?)(?:[\s._-]*)?\((?P<index>\d{1,3})\)(?P<ext>\.[^.]+)?$"
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class DedupCandidate:
29
+ """待治理的冲突副本或重复项。"""
30
+
31
+ path: str
32
+ name: str
33
+ isdir: bool
34
+ size: int
35
+ reason: str
36
+ clean_name: str
37
+ primary_path: str | None = None
38
+
39
+
40
+ @dataclass
41
+ class DedupPlan:
42
+ """重复项治理扫描计划与统计。"""
43
+
44
+ target_dir: str
45
+ candidates: list[DedupCandidate]
46
+ total_size: int
47
+ scanned_count: int
48
+ orphan_groups_count: int = 0
49
+
50
+
51
+ def scan_directory_duplicates(
52
+ dir_path: str,
53
+ entries: list[dict[str, Any]],
54
+ include_orphans: bool = False,
55
+ include_empty: bool = False,
56
+ mgr: FileManager | None = None,
57
+ ) -> tuple[list[DedupCandidate], int]:
58
+ """在单层目录条目中检测冲突副本与重复项。
59
+
60
+ Args:
61
+ dir_path: 当前扫描的父目录绝对路径。
62
+ entries: 当前目录下的条目列表(来自 list_dir 或 walk 分组)。
63
+ include_orphans: 是否将无主冲突副本组(如缺少未带编号原版但有 (1)/(2))中更高编号项视为冗余。
64
+ include_empty: 是否检测并标记空子目录。
65
+ mgr: 可选的 FileManager 实例,用于单层模式下探查子目录是否为空。
66
+
67
+ Returns:
68
+ (candidates, orphan_groups_count) 二元组。
69
+ """
70
+ entry_map: dict[str, dict[str, Any]] = {
71
+ e.get("server_filename", ""): e for e in entries if e.get("server_filename")
72
+ }
73
+ candidates: list[DedupCandidate] = []
74
+ orphan_groups: dict[tuple[str, str, bool], list[tuple[int, dict[str, Any]]]] = defaultdict(list)
75
+
76
+ for e in entries:
77
+ fname = e.get("server_filename", "")
78
+ if not fname:
79
+ continue
80
+ isdir = e.get("isdir") == 1
81
+ path = e.get("path") or f"{dir_path.rstrip('/')}/{fname}"
82
+ size = int(e.get("size") or 0)
83
+
84
+ # 1. 检测空目录(若启用)
85
+ if include_empty and isdir:
86
+ is_empty = False
87
+ if mgr is not None:
88
+ try:
89
+ sub_entries = mgr.list_dir(path, limit=1)
90
+ if len(sub_entries) == 0:
91
+ is_empty = True
92
+ except Exception as ex:
93
+ logger.debug("探查目录是否为空失败 [%s]: %s", path, ex)
94
+ if is_empty:
95
+ candidates.append(
96
+ DedupCandidate(
97
+ path=path,
98
+ name=fname,
99
+ isdir=True,
100
+ size=0,
101
+ reason="空目录 (0 项)",
102
+ clean_name=fname,
103
+ )
104
+ )
105
+ continue
106
+
107
+ # 2. 匹配冲突副本正则 (1), (2) ...
108
+ m = CONFLICT_SUFFIX_PATTERN.match(fname)
109
+ if not m:
110
+ continue
111
+
112
+ base = m.group("base").rstrip(" ._-")
113
+ idx = int(m.group("index"))
114
+ ext = m.group("ext") or ""
115
+ clean_name = f"{base}{ext}" if not isdir else base
116
+
117
+ # 检查同目录下是否存在未带序号的主条目
118
+ if clean_name in entry_map and entry_map[clean_name].get("isdir") == e.get("isdir"):
119
+ primary = entry_map[clean_name]
120
+ if not isdir:
121
+ if size == int(primary.get("size") or 0):
122
+ reason = f"存在同名主文件 '{clean_name}' 且大小一致 ({idx})"
123
+ else:
124
+ reason = f"存在同名主文件 '{clean_name}' (序号冲突副本 ({idx}))"
125
+ else:
126
+ reason = f"存在同名主目录 '{clean_name}' (冲突副本 ({idx}))"
127
+
128
+ candidates.append(
129
+ DedupCandidate(
130
+ path=path,
131
+ name=fname,
132
+ isdir=isdir,
133
+ size=size,
134
+ reason=reason,
135
+ clean_name=clean_name,
136
+ primary_path=primary.get("path"),
137
+ )
138
+ )
139
+ else:
140
+ orphan_groups[(base, ext, isdir)].append((idx, e))
141
+
142
+ # 3. 处理无主冲突副本组
143
+ orphan_groups_count = 0
144
+ for (base, ext, isdir), group in orphan_groups.items():
145
+ if len(group) > 1:
146
+ orphan_groups_count += 1
147
+ if include_orphans:
148
+ # 排序保留序号最小者,更高序号作为冗余候选
149
+ sorted_group = sorted(group, key=lambda x: x[0])
150
+ kept_idx, kept_entry = sorted_group[0]
151
+ kept_name = kept_entry.get("server_filename", "")
152
+ clean_name = f"{base}{ext}" if not isdir else base
153
+ for idx, item in sorted_group[1:]:
154
+ item_path = item.get("path") or f"{dir_path.rstrip('/')}/{item.get('server_filename')}"
155
+ item_name = item.get("server_filename", "")
156
+ candidates.append(
157
+ DedupCandidate(
158
+ path=item_path,
159
+ name=item_name,
160
+ isdir=isdir,
161
+ size=int(item.get("size") or 0),
162
+ reason=f"无主条目,同组保留最低序号 '{kept_name}' (冗余副本 ({idx}))",
163
+ clean_name=clean_name,
164
+ primary_path=kept_entry.get("path"),
165
+ )
166
+ )
167
+
168
+ return candidates, orphan_groups_count
169
+
170
+
171
+ def plan_dedup(
172
+ dir_path: str,
173
+ mgr: FileManager,
174
+ recursive: bool = False,
175
+ include_orphans: bool = False,
176
+ include_empty: bool = False,
177
+ ) -> DedupPlan:
178
+ """扫描指定网盘目录并规划冲突副本治理方案。
179
+
180
+ Args:
181
+ dir_path: 网盘目标目录。
182
+ mgr: FileManager 实例。
183
+ recursive: 是否递归扫描所有子目录。
184
+ include_orphans: 是否处理无主冲突副本组(保留 (1),清理 (2)+)。
185
+ include_empty: 是否清理空目录。
186
+
187
+ Returns:
188
+ DedupPlan 规划结果对象。
189
+ """
190
+ clean_dir = "/" + dir_path.strip("/") if dir_path != "/" else "/"
191
+ logger.info("开始扫描冲突副本: target=%s, recursive=%s", clean_dir, recursive)
192
+
193
+ if not recursive:
194
+ entries = mgr.list_dir(clean_dir)
195
+ candidates, orphan_count = scan_directory_duplicates(
196
+ clean_dir,
197
+ entries,
198
+ include_orphans=include_orphans,
199
+ include_empty=include_empty,
200
+ mgr=mgr,
201
+ )
202
+ total_size = sum(c.size for c in candidates)
203
+ return DedupPlan(
204
+ target_dir=clean_dir,
205
+ candidates=candidates,
206
+ total_size=total_size,
207
+ scanned_count=len(entries),
208
+ orphan_groups_count=orphan_count,
209
+ )
210
+
211
+ # 递归模式:通过 DFS walk 遍历全子树并按父目录分组治理
212
+ entries, errors = mgr.walk(clean_dir)
213
+ by_parent: dict[str, list[dict[str, Any]]] = defaultdict(list)
214
+ all_dir_paths: set[str] = set()
215
+
216
+ for _, entry in entries:
217
+ p = entry.get("path", "")
218
+ if entry.get("isdir") == 1:
219
+ all_dir_paths.add(p)
220
+ parent = os.path.dirname(p) or "/"
221
+ by_parent[parent].append(entry)
222
+
223
+ all_candidates: list[DedupCandidate] = []
224
+ total_orphan_count = 0
225
+
226
+ # 逐目录检测冲突副本
227
+ for parent_dir, dir_entries in by_parent.items():
228
+ cand, orph = scan_directory_duplicates(
229
+ parent_dir,
230
+ dir_entries,
231
+ include_orphans=include_orphans,
232
+ include_empty=False, # 递归模式下空目录统一在下方处理
233
+ mgr=None,
234
+ )
235
+ all_candidates.extend(cand)
236
+ total_orphan_count += orph
237
+
238
+ # 递归模式检测空目录(若开启)
239
+ if include_empty:
240
+ non_empty_parents = set(by_parent.keys())
241
+ for dpath in all_dir_paths:
242
+ # 若一个目录不在任何条目的父目录集合中,且自身不是由已标记候选构成的
243
+ if dpath not in non_empty_parents:
244
+ dname = os.path.basename(dpath)
245
+ all_candidates.append(
246
+ DedupCandidate(
247
+ path=dpath,
248
+ name=dname,
249
+ isdir=True,
250
+ size=0,
251
+ reason="空目录 (0 项)",
252
+ clean_name=dname,
253
+ )
254
+ )
255
+
256
+ total_size = sum(c.size for c in all_candidates)
257
+ return DedupPlan(
258
+ target_dir=clean_dir,
259
+ candidates=all_candidates,
260
+ total_size=total_size,
261
+ scanned_count=len(entries),
262
+ orphan_groups_count=total_orphan_count,
263
+ )
264
+
265
+
266
+ def execute_dedup(
267
+ plan: DedupPlan,
268
+ mgr: FileManager,
269
+ staging_dir: str | None = None,
270
+ delete: bool = False,
271
+ batch_size: int = 50,
272
+ ) -> dict[str, int]:
273
+ """执行重复项治理计划(移动到暂存目录或直接删除)。
274
+
275
+ Args:
276
+ plan: 治理计划。
277
+ mgr: FileManager 实例。
278
+ staging_dir: 隔离暂存目录路径(若指定则批量移动至该目录)。
279
+ delete: 若为 True 则直接移入网盘回收站。
280
+ batch_size: 每批次提交上限(默认 50)。
281
+
282
+ Returns:
283
+ 包含 'moved' 和 'deleted' 数量的字典。
284
+ """
285
+ if not plan.candidates:
286
+ return {"moved": 0, "deleted": 0}
287
+
288
+ if staging_dir:
289
+ clean_staging = "/" + staging_dir.strip("/")
290
+ # 确保隔离目录存在
291
+ try:
292
+ mgr.mkdir(clean_staging)
293
+ except FileManagerError:
294
+ pass
295
+
296
+ items_to_move = [
297
+ (c.path, clean_staging, c.name)
298
+ for c in plan.candidates
299
+ ]
300
+ logger.info("正在批量移动 %d 个冲突副本至 %s", len(items_to_move), clean_staging)
301
+ mgr.batch_move(items_to_move, batch_size=batch_size)
302
+ return {"moved": len(items_to_move), "deleted": 0}
303
+
304
+ if delete:
305
+ paths_to_delete = [c.path for c in plan.candidates]
306
+ logger.info("正在批量删除 %d 个冲突副本入回收站", len(paths_to_delete))
307
+ mgr.batch_delete(paths_to_delete, batch_size=batch_size)
308
+ return {"moved": 0, "deleted": len(paths_to_delete)}
309
+
310
+ return {"moved": 0, "deleted": 0}
@@ -0,0 +1,178 @@
1
+ """Downloader: 百度网盘文件直链提取与多协议/分块断点续传下载引擎。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+
13
+ from bdpan_cli._vendor import openapi_client
14
+ from bdpan_cli._vendor.openapi_client.api.multimediafile_api import MultimediafileApi
15
+ from bdpan_cli._vendor.openapi_client.rest import ApiException
16
+ from bdpan_cli.file_manager import FileManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # 官方防盗链/限速必需请求头
21
+ BAIDU_PAN_UA = "pan.baidu.com"
22
+ CHUNK_READ_SIZE = 256 * 1024 # 256KB 缓冲区
23
+
24
+
25
+ class DownloadError(RuntimeError):
26
+ """文件下载或直链获取异常基类。"""
27
+
28
+
29
+ class Downloader:
30
+ """网盘文件直链提取与断点续传下载器。"""
31
+
32
+ def __init__(self, access_token: str, api_client: openapi_client.ApiClient | None = None) -> None:
33
+ self.access_token = access_token
34
+ self.api_client = api_client or openapi_client.ApiClient()
35
+ self._multimedia_api = MultimediafileApi(self.api_client)
36
+ self._file_manager = FileManager(access_token, self.api_client)
37
+
38
+ def get_dlink(self, remote_path: str) -> dict[str, Any]:
39
+ """获取单个网盘文件的真实下载直链 (dlink) 与元数据。
40
+
41
+ Args:
42
+ remote_path: 网盘文件绝对路径。
43
+
44
+ Returns:
45
+ 条目元数据字典,包含 dlink、fs_id、size、md5、server_filename 等。
46
+
47
+ Raises:
48
+ DownloadError: 当文件不存在、为目录或接口返回异常时抛出。
49
+ """
50
+ info = self._file_manager.get_file_info(remote_path)
51
+ if not info:
52
+ raise DownloadError(f"网盘文件不存在: {remote_path}")
53
+ if info.get("isdir") == 1:
54
+ raise DownloadError(f"目标为目录,不支持直接获取直链下载: {remote_path}")
55
+
56
+ fs_id = info["fs_id"]
57
+ try:
58
+ res = self._multimedia_api.xpanmultimediafilemetas(
59
+ access_token=self.access_token,
60
+ fsids=json.dumps([fs_id]),
61
+ dlink="1",
62
+ )
63
+ except ApiException as e:
64
+ raise DownloadError(f"获取直链 API 异常 [{remote_path}]: {e.status} {e.body}") from e
65
+
66
+ if not isinstance(res, dict) or res.get("errno") != 0:
67
+ raise DownloadError(f"获取直链失败 [{remote_path}]: errno={res.get('errno', '?')}")
68
+
69
+ entries = res.get("list") or []
70
+ if not entries:
71
+ raise DownloadError(f"获取直链返回空列表 [{remote_path}]")
72
+
73
+ meta = entries[0]
74
+ if not meta.get("dlink"):
75
+ raise DownloadError(f"未能解析到有效直链 [{remote_path}]")
76
+
77
+ # 合并 path 与 server_filename
78
+ meta["path"] = remote_path
79
+ meta["server_filename"] = info.get("server_filename", os.path.basename(remote_path))
80
+ return meta
81
+
82
+ def download_file(
83
+ self,
84
+ remote_path: str,
85
+ local_dest: str | Path,
86
+ overwrite: bool = False,
87
+ progress: Callable[[dict[str, Any]], None] | None = None,
88
+ ) -> str:
89
+ """下载网盘文件至本地,支持断点续传与 User-Agent 防盗链穿透。
90
+
91
+ Args:
92
+ remote_path: 网盘文件路径。
93
+ local_dest: 本地目标文件路径或目标目录。
94
+ overwrite: 若本地目标已存在是否覆盖。
95
+ progress: 进度回调函数,接收 phase, downloaded, total, speed 等事件。
96
+
97
+ Returns:
98
+ 本地最终完整文件路径。
99
+ """
100
+ meta = self.get_dlink(remote_path)
101
+ dlink = meta["dlink"]
102
+ total_size = meta.get("size", 0)
103
+ filename = meta.get("server_filename") or os.path.basename(remote_path)
104
+
105
+ local_dest_path = Path(local_dest).resolve()
106
+ if local_dest_path.is_dir():
107
+ target_file = local_dest_path / filename
108
+ else:
109
+ target_file = local_dest_path
110
+
111
+ if target_file.exists() and not overwrite:
112
+ if target_file.stat().st_size == total_size:
113
+ logger.info("本地已存在同名且大小一致的文件,跳过下载: %s", target_file)
114
+ return str(target_file)
115
+ raise DownloadError(f"本地文件已存在且未开启 overwrite: {target_file}")
116
+
117
+ target_file.parent.mkdir(parents=True, exist_ok=True)
118
+ tmp_file = target_file.with_name(target_file.name + ".tmp")
119
+
120
+ # 断点续传检查
121
+ current_size = tmp_file.stat().st_size if tmp_file.exists() else 0
122
+ if current_size > total_size:
123
+ # 临时文件损坏,重新开始
124
+ current_size = 0
125
+ tmp_file.unlink(missing_ok=True)
126
+
127
+ req = urllib.request.Request(dlink)
128
+ req.add_header("User-Agent", BAIDU_PAN_UA)
129
+ if current_size > 0:
130
+ req.add_header("Range", f"bytes={current_size}-")
131
+
132
+ mode = "ab" if current_size > 0 else "wb"
133
+ downloaded = current_size
134
+ start_time = time.time()
135
+ last_report_time = start_time
136
+
137
+ try:
138
+ with urllib.request.urlopen(req, timeout=60) as resp, open(tmp_file, mode) as f:
139
+ while True:
140
+ buf = resp.read(CHUNK_READ_SIZE)
141
+ if not buf:
142
+ break
143
+ f.write(buf)
144
+ downloaded += len(buf)
145
+
146
+ now = time.time()
147
+ if progress and (now - last_report_time >= 0.5 or downloaded == total_size):
148
+ elapsed = now - start_time
149
+ speed = (downloaded - current_size) / elapsed if elapsed > 0 else 0
150
+ progress({
151
+ "phase": "download",
152
+ "downloaded": downloaded,
153
+ "total": total_size,
154
+ "speed": speed,
155
+ "filename": filename,
156
+ })
157
+ last_report_time = now
158
+ except Exception as e:
159
+ raise DownloadError(f"下载传输异常 [{remote_path}]: {e}") from e
160
+
161
+ # 校验下载完整性
162
+ if total_size > 0 and downloaded < total_size:
163
+ raise DownloadError(f"下载不完整 [{downloaded}/{total_size} 字节],已保留临时文件供续传: {tmp_file}")
164
+
165
+ # 原子重命名为正式文件
166
+ tmp_file.replace(target_file)
167
+ return str(target_file)
168
+
169
+ @staticmethod
170
+ def generate_export_command(dlink: str, filename: str, style: str = "aria2") -> str:
171
+ """生成便于外部工具(aria2c 或 curl)高速下载的命令。"""
172
+ if style.lower() == "curl":
173
+ return f'curl -C - -H "User-Agent: {BAIDU_PAN_UA}" -o "{filename}" "{dlink}"'
174
+ # 默认 aria2
175
+ return (
176
+ f'aria2c -c -s16 -x16 --header="User-Agent: {BAIDU_PAN_UA}" '
177
+ f'"{dlink}" -o "{filename}"'
178
+ )