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/auth.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""access_token 的获取与缓存(设备码模式)。
|
|
2
|
+
|
|
3
|
+
access_token 有效期 30 天,refresh_token 有效期 10 年。
|
|
4
|
+
缓存文件不提交到 git(见 .gitignore)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bdpan_cli._vendor import openapi_client
|
|
14
|
+
from bdpan_cli._vendor.openapi_client.api.auth_api import AuthApi
|
|
15
|
+
from bdpan_cli._vendor.openapi_client.rest import ApiException
|
|
16
|
+
from bdpan_cli.config import AppConfig
|
|
17
|
+
|
|
18
|
+
# 默认用户端 Token 凭据路径遵循 XDG 规范 (~/.config/bdpan/tokens.json)
|
|
19
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "bdpan"
|
|
20
|
+
DEFAULT_TOKEN_FILE = DEFAULT_CONFIG_DIR / "tokens.json"
|
|
21
|
+
LEGACY_TOKEN_CACHE = Path(__file__).resolve().parents[2] / ".baidupan_tokens.json"
|
|
22
|
+
|
|
23
|
+
# 向后兼容常量导出
|
|
24
|
+
TOKEN_CACHE = DEFAULT_TOKEN_FILE
|
|
25
|
+
SCOPE = "basic,netdisk"
|
|
26
|
+
REFRESH_BEFORE_EXPIRY = 24 * 3600 # 剩余不足一天时先刷新
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthorizationNeeded(Exception):
|
|
30
|
+
"""需要用户完成设备码授权。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenManager:
|
|
34
|
+
"""网盘授权凭证管理器,支持多账号别名存储与动态切换。"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
config: AppConfig,
|
|
39
|
+
account: str | None = None,
|
|
40
|
+
cache_path: Path | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._account = account
|
|
44
|
+
if cache_path:
|
|
45
|
+
self._cache_path = Path(cache_path)
|
|
46
|
+
else:
|
|
47
|
+
env_path = os.environ.get("BDPAN_TOKEN_PATH")
|
|
48
|
+
self._cache_path = Path(env_path) if env_path else DEFAULT_TOKEN_FILE
|
|
49
|
+
|
|
50
|
+
def _normalize_token_data(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""标准化 token 字典,平滑兼容旧单账号形态与多账号形态。"""
|
|
52
|
+
if "access_token" in raw:
|
|
53
|
+
return {
|
|
54
|
+
"current": "default",
|
|
55
|
+
"accounts": {
|
|
56
|
+
"default": {
|
|
57
|
+
"access_token": raw.get("access_token"),
|
|
58
|
+
"refresh_token": raw.get("refresh_token"),
|
|
59
|
+
"expires_at": raw.get("expires_at"),
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
if "accounts" not in raw or not isinstance(raw["accounts"], dict):
|
|
64
|
+
raw["accounts"] = {}
|
|
65
|
+
if "current" not in raw:
|
|
66
|
+
raw["current"] = "default"
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
def _load_all(self) -> dict[str, Any]:
|
|
70
|
+
"""读取并兼容迁移缓存数据,返回统一的多账号结构字典。"""
|
|
71
|
+
# 若目标文件不存在,但存在旧项目级 .baidupan_tokens.json,则自动无缝迁移至 ~/.config/bdpan/tokens.json
|
|
72
|
+
if not self._cache_path.exists():
|
|
73
|
+
if self._cache_path == DEFAULT_TOKEN_FILE and LEGACY_TOKEN_CACHE.exists():
|
|
74
|
+
try:
|
|
75
|
+
legacy_data = json.loads(LEGACY_TOKEN_CACHE.read_text(encoding="utf-8"))
|
|
76
|
+
migrated = self._normalize_token_data(legacy_data)
|
|
77
|
+
self._save_all(migrated)
|
|
78
|
+
return migrated
|
|
79
|
+
except (OSError, json.JSONDecodeError):
|
|
80
|
+
pass
|
|
81
|
+
return {"current": "default", "accounts": {}}
|
|
82
|
+
try:
|
|
83
|
+
raw = json.loads(self._cache_path.read_text(encoding="utf-8"))
|
|
84
|
+
if not isinstance(raw, dict):
|
|
85
|
+
return {"current": "default", "accounts": {}}
|
|
86
|
+
if "access_token" in raw:
|
|
87
|
+
migrated = self._normalize_token_data(raw)
|
|
88
|
+
self._save_all(migrated)
|
|
89
|
+
return migrated
|
|
90
|
+
return self._normalize_token_data(raw)
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return {"current": "default", "accounts": {}}
|
|
93
|
+
|
|
94
|
+
def _save_all(self, data: dict[str, Any]) -> None:
|
|
95
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
self._cache_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
97
|
+
try:
|
|
98
|
+
self._cache_path.chmod(0o600)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def get_active_account(self) -> str:
|
|
103
|
+
"""返回当前生效的账号别名。"""
|
|
104
|
+
if self._account:
|
|
105
|
+
return self._account
|
|
106
|
+
all_data = self._load_all()
|
|
107
|
+
return all_data.get("current") or "default"
|
|
108
|
+
|
|
109
|
+
def get_access_token(self) -> str:
|
|
110
|
+
account = self.get_active_account()
|
|
111
|
+
all_data = self._load_all()
|
|
112
|
+
cached = all_data.get("accounts", {}).get(account)
|
|
113
|
+
if cached and "expires_at" in cached and "access_token" in cached:
|
|
114
|
+
if cached["expires_at"] - time.time() > REFRESH_BEFORE_EXPIRY:
|
|
115
|
+
return cached["access_token"]
|
|
116
|
+
refreshed = self._refresh(cached.get("refresh_token", ""))
|
|
117
|
+
if refreshed:
|
|
118
|
+
return refreshed["access_token"]
|
|
119
|
+
return self._device_flow()["access_token"]
|
|
120
|
+
|
|
121
|
+
def _load_cache(self) -> dict[str, Any] | None:
|
|
122
|
+
all_data = self._load_all()
|
|
123
|
+
account = self.get_active_account()
|
|
124
|
+
return all_data.get("accounts", {}).get(account)
|
|
125
|
+
|
|
126
|
+
def _save_cache(self, token: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
account = self.get_active_account()
|
|
128
|
+
all_data = self._load_all()
|
|
129
|
+
if "accounts" not in all_data or not isinstance(all_data["accounts"], dict):
|
|
130
|
+
all_data["accounts"] = {}
|
|
131
|
+
all_data["accounts"][account] = token
|
|
132
|
+
all_data["current"] = account
|
|
133
|
+
self._save_all(all_data)
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
def list_accounts(self) -> list[dict[str, Any]]:
|
|
137
|
+
"""列出本地所有保存的网盘账号信息。"""
|
|
138
|
+
all_data = self._load_all()
|
|
139
|
+
curr = all_data.get("current") or "default"
|
|
140
|
+
accounts = all_data.get("accounts") or {}
|
|
141
|
+
res = []
|
|
142
|
+
for name, acc in accounts.items():
|
|
143
|
+
expires_at = acc.get("expires_at") or 0
|
|
144
|
+
is_valid = expires_at > time.time()
|
|
145
|
+
res.append({
|
|
146
|
+
"account": name,
|
|
147
|
+
"is_current": (name == curr),
|
|
148
|
+
"expires_at": expires_at,
|
|
149
|
+
"valid": is_valid,
|
|
150
|
+
})
|
|
151
|
+
return res
|
|
152
|
+
|
|
153
|
+
def switch_account(self, account: str) -> None:
|
|
154
|
+
"""切换默认活跃账号。"""
|
|
155
|
+
all_data = self._load_all()
|
|
156
|
+
if account not in all_data.get("accounts", {}):
|
|
157
|
+
raise ValueError(f"账号 [{account}] 不存在,请先使用设备码授权登录该账号")
|
|
158
|
+
all_data["current"] = account
|
|
159
|
+
self._save_all(all_data)
|
|
160
|
+
|
|
161
|
+
def remove_account(self, account: str) -> None:
|
|
162
|
+
"""移除指定账号凭证。"""
|
|
163
|
+
all_data = self._load_all()
|
|
164
|
+
accounts = all_data.get("accounts", {})
|
|
165
|
+
if account in accounts:
|
|
166
|
+
del accounts[account]
|
|
167
|
+
if all_data.get("current") == account:
|
|
168
|
+
all_data["current"] = next(iter(accounts.keys())) if accounts else "default"
|
|
169
|
+
self._save_all(all_data)
|
|
170
|
+
|
|
171
|
+
def _to_cache(self, response) -> dict:
|
|
172
|
+
return {
|
|
173
|
+
"access_token": response.access_token,
|
|
174
|
+
"refresh_token": response.refresh_token,
|
|
175
|
+
"expires_at": int(time.time()) + int(response.expires_in),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def _refresh(self, refresh_token: str) -> dict | None:
|
|
179
|
+
if not refresh_token:
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
with openapi_client.ApiClient() as client:
|
|
183
|
+
response = AuthApi(client).oauth_token_refresh_token(
|
|
184
|
+
refresh_token, self._config.app_key, self._config.secret_key
|
|
185
|
+
)
|
|
186
|
+
except ApiException:
|
|
187
|
+
return None
|
|
188
|
+
return self._save_cache(self._to_cache(response))
|
|
189
|
+
|
|
190
|
+
def _device_flow(self) -> dict:
|
|
191
|
+
with openapi_client.ApiClient() as client:
|
|
192
|
+
auth = AuthApi(client)
|
|
193
|
+
device = auth.oauth_token_device_code(self._config.app_key, SCOPE)
|
|
194
|
+
interval = max(int(device.interval or 5), 3)
|
|
195
|
+
deadline = time.time() + int(device.expires_in or 300)
|
|
196
|
+
print(
|
|
197
|
+
f"请打开 {device.verification_url} ,输入用户码 {device.user_code} 并确认授权",
|
|
198
|
+
flush=True,
|
|
199
|
+
)
|
|
200
|
+
while time.time() < deadline:
|
|
201
|
+
time.sleep(interval)
|
|
202
|
+
try:
|
|
203
|
+
response = auth.oauth_token_device_token(
|
|
204
|
+
device.device_code,
|
|
205
|
+
self._config.app_key,
|
|
206
|
+
self._config.secret_key,
|
|
207
|
+
)
|
|
208
|
+
except ApiException as e:
|
|
209
|
+
body = getattr(e, "body", "") or ""
|
|
210
|
+
if "authorization_pending" in body:
|
|
211
|
+
continue
|
|
212
|
+
if "slow_down" in body:
|
|
213
|
+
interval += 5
|
|
214
|
+
continue
|
|
215
|
+
raise RuntimeError(f"设备码授权失败:{body}") from e
|
|
216
|
+
print("授权成功", flush=True)
|
|
217
|
+
return self._save_cache(self._to_cache(response))
|
|
218
|
+
raise AuthorizationNeeded("设备码已过期,请重新运行")
|