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
@@ -0,0 +1,385 @@
1
+ # !/usr/bin/env python3
2
+ """
3
+ xpan
4
+
5
+ xpanapi # noqa: E501
6
+
7
+ The version of the OpenAPI document: 0.1
8
+ Generated by: https://openapi-generator.tech
9
+ """
10
+
11
+
12
+ import io
13
+ import json
14
+ import logging
15
+ import re
16
+ import ssl
17
+ from urllib.parse import urlencode
18
+ from urllib.parse import urlparse
19
+ from urllib.request import proxy_bypass_environment
20
+ import urllib3
21
+ import ipaddress
22
+
23
+ from bdpan_cli._vendor.openapi_client.exceptions import ApiException, UnauthorizedException, ForbiddenException
24
+ from bdpan_cli._vendor.openapi_client.exceptions import NotFoundException, ServiceException, ApiValueError
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class RESTResponse(io.IOBase):
31
+ """
32
+ class RESTResponse
33
+ """
34
+
35
+ def __init__(self, resp):
36
+ """
37
+ init
38
+ """
39
+ self.urllib3_response = resp
40
+ self.status = resp.status
41
+ self.reason = resp.reason
42
+ self.data = resp.data
43
+
44
+ def getheaders(self):
45
+ """Returns a dictionary of the response headers."""
46
+ return self.urllib3_response.getheaders()
47
+
48
+ def getheader(self, name, default=None):
49
+ """Returns a given response header."""
50
+ return self.urllib3_response.getheader(name, default)
51
+
52
+
53
+ class RESTClientObject(object):
54
+ """
55
+ class RESTClientObject
56
+ """
57
+
58
+ def __init__(self, configuration, pools_size=4, maxsize=None):
59
+ # urllib3.PoolManager will pass all kw parameters to connectionpool
60
+ # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/poolmanager.py#L75 # noqa: E501
61
+ # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/connectionpool.py#L680 # noqa: E501
62
+ # maxsize is the number of requests to host that are allowed in parallel # noqa: E501
63
+ # Custom SSL certificates and client certificates: http://urllib3.readthedocs.io/en/latest/advanced-usage.html # noqa: E501
64
+
65
+ # cert_reqs
66
+ if configuration.verify_ssl:
67
+ cert_reqs = ssl.CERT_REQUIRED
68
+ else:
69
+ cert_reqs = ssl.CERT_NONE
70
+
71
+ addition_pool_args = {}
72
+ if configuration.assert_hostname is not None:
73
+ addition_pool_args['assert_hostname'] = configuration.assert_hostname # noqa: E501
74
+
75
+ if configuration.retries is not None:
76
+ addition_pool_args['retries'] = configuration.retries
77
+
78
+ if configuration.socket_options is not None:
79
+ addition_pool_args['socket_options'] = configuration.socket_options
80
+
81
+ if maxsize is None:
82
+ if configuration.connection_pool_maxsize is not None:
83
+ maxsize = configuration.connection_pool_maxsize
84
+ else:
85
+ maxsize = 4
86
+
87
+ # https pool manager
88
+ if configuration.proxy and not should_bypass_proxies(configuration.host, no_proxy=configuration.no_proxy or ''):
89
+ self.pool_manager = urllib3.ProxyManager(
90
+ num_pools=pools_size,
91
+ maxsize=maxsize,
92
+ cert_reqs=cert_reqs,
93
+ ca_certs=configuration.ssl_ca_cert,
94
+ cert_file=configuration.cert_file,
95
+ key_file=configuration.key_file,
96
+ proxy_url=configuration.proxy,
97
+ proxy_headers=configuration.proxy_headers,
98
+ **addition_pool_args
99
+ )
100
+ else:
101
+ self.pool_manager = urllib3.PoolManager(
102
+ num_pools=pools_size,
103
+ maxsize=maxsize,
104
+ cert_reqs=cert_reqs,
105
+ ca_certs=configuration.ssl_ca_cert,
106
+ cert_file=configuration.cert_file,
107
+ key_file=configuration.key_file,
108
+ **addition_pool_args
109
+ )
110
+
111
+ def request(self, method, url, query_params=None, headers=None,
112
+ body=None, post_params=None, _preload_content=True,
113
+ _request_timeout=None):
114
+ """Perform requests.
115
+
116
+ :param method: http request method
117
+ :param url: http request url
118
+ :param query_params: query parameters in the url
119
+ :param headers: http request headers
120
+ :param body: request json body, for `application/json`
121
+ :param post_params: request post parameters,
122
+ `application/x-www-form-urlencoded`
123
+ and `multipart/form-data`
124
+ :param _preload_content: if False, the urllib3.HTTPResponse object will
125
+ be returned without reading/decoding response
126
+ data. Default is True.
127
+ :param _request_timeout: timeout setting for this request. If one
128
+ number provided, it will be total request
129
+ timeout. It can also be a pair (tuple) of
130
+ (connection, read) timeouts.
131
+ """
132
+ method = method.upper()
133
+ assert method in ['GET', 'HEAD', 'DELETE', 'POST', 'PUT',
134
+ 'PATCH', 'OPTIONS']
135
+
136
+ if post_params and body:
137
+ raise ApiValueError(
138
+ "body parameter cannot be used with post_params parameter."
139
+ )
140
+
141
+ post_params = post_params or {}
142
+ headers = headers or {}
143
+
144
+ timeout = None
145
+ if _request_timeout:
146
+ if isinstance(_request_timeout, (int, float)): # noqa: E501,F821
147
+ timeout = urllib3.Timeout(total=_request_timeout)
148
+ elif (isinstance(_request_timeout, tuple) and
149
+ len(_request_timeout) == 2):
150
+ timeout = urllib3.Timeout(
151
+ connect=_request_timeout[0], read=_request_timeout[1])
152
+
153
+ try:
154
+ # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
155
+ if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
156
+ # Only set a default Content-Type for POST, PUT, PATCH and OPTIONS requests
157
+ if (method != 'DELETE') and ('Content-Type' not in headers):
158
+ headers['Content-Type'] = 'application/json'
159
+ if query_params:
160
+ url += '&' + urlencode(query_params)
161
+ # url += '?' + urlencode(query_params)
162
+ if ('Content-Type' not in headers) or (re.search('json', headers['Content-Type'], re.IGNORECASE)):
163
+ request_body = None
164
+ if body is not None:
165
+ request_body = json.dumps(body)
166
+ r = self.pool_manager.request(
167
+ method, url,
168
+ body=request_body,
169
+ preload_content=_preload_content,
170
+ timeout=timeout,
171
+ headers=headers)
172
+ elif headers['Content-Type'] == 'application/x-www-form-urlencoded': # noqa: E501
173
+ r = self.pool_manager.request(
174
+ method, url,
175
+ fields=post_params,
176
+ encode_multipart=False,
177
+ preload_content=_preload_content,
178
+ timeout=timeout,
179
+ headers=headers)
180
+ elif headers['Content-Type'] == 'multipart/form-data':
181
+ # must del headers['Content-Type'], or the correct
182
+ # Content-Type which generated by urllib3 will be
183
+ # overwritten.
184
+ del headers['Content-Type']
185
+ r = self.pool_manager.request(
186
+ method, url,
187
+ fields=post_params,
188
+ encode_multipart=True,
189
+ preload_content=_preload_content,
190
+ timeout=timeout,
191
+ headers=headers)
192
+ # Pass a `string` parameter directly in the body to support
193
+ # other content types than Json when `body` argument is
194
+ # provided in serialized form
195
+ elif isinstance(body, str) or isinstance(body, bytes):
196
+ request_body = body
197
+ r = self.pool_manager.request(
198
+ method, url,
199
+ body=request_body,
200
+ preload_content=_preload_content,
201
+ timeout=timeout,
202
+ headers=headers)
203
+ else:
204
+ # Cannot generate the request from given parameters
205
+ msg = """Cannot prepare a request message for provided
206
+ arguments. Please check that your arguments match
207
+ declared content type."""
208
+ raise ApiException(status=0, reason=msg)
209
+ # For `GET`, `HEAD`
210
+ else:
211
+ r = self.pool_manager.request(method, url,
212
+ # fields=query_params,
213
+ preload_content=_preload_content,
214
+ timeout=timeout,
215
+ headers=headers)
216
+ except urllib3.exceptions.SSLError as e:
217
+ msg = "{0}\n{1}".format(type(e).__name__, str(e))
218
+ raise ApiException(status=0, reason=msg)
219
+
220
+ if _preload_content:
221
+ r = RESTResponse(r)
222
+
223
+ # log response body
224
+ logger.debug("response body: %s", r.data)
225
+
226
+ if not 200 <= r.status <= 299:
227
+ if r.status == 401:
228
+ raise UnauthorizedException(http_resp=r)
229
+
230
+ if r.status == 403:
231
+ raise ForbiddenException(http_resp=r)
232
+
233
+ if r.status == 404:
234
+ raise NotFoundException(http_resp=r)
235
+
236
+ if 500 <= r.status <= 599:
237
+ raise ServiceException(http_resp=r)
238
+
239
+ raise ApiException(http_resp=r)
240
+
241
+ return r
242
+
243
+ def GET(self, url, headers=None, query_params=None, _preload_content=True,
244
+ _request_timeout=None):
245
+ """
246
+ HTTP Get
247
+ """
248
+ if query_params:
249
+ url += '&' + urlencode(query_params)
250
+ # print(url)
251
+ return self.request("GET", url,
252
+ headers=headers,
253
+ _preload_content=_preload_content,
254
+ _request_timeout=_request_timeout,
255
+ query_params=query_params)
256
+
257
+ def HEAD(self, url, headers=None, query_params=None, _preload_content=True,
258
+ _request_timeout=None):
259
+ """
260
+ HTTP HEAD
261
+ """
262
+ return self.request("HEAD", url,
263
+ headers=headers,
264
+ _preload_content=_preload_content,
265
+ _request_timeout=_request_timeout,
266
+ query_params=query_params)
267
+
268
+ def OPTIONS(self, url, headers=None, query_params=None, post_params=None,
269
+ body=None, _preload_content=True, _request_timeout=None):
270
+ """
271
+ HTTP OPTIONS
272
+ """
273
+ return self.request("OPTIONS", url,
274
+ headers=headers,
275
+ query_params=query_params,
276
+ post_params=post_params,
277
+ _preload_content=_preload_content,
278
+ _request_timeout=_request_timeout,
279
+ body=body)
280
+
281
+ def DELETE(self, url, headers=None, query_params=None, body=None,
282
+ _preload_content=True, _request_timeout=None):
283
+ """
284
+ HTTP DELETE
285
+ """
286
+ return self.request("DELETE", url,
287
+ headers=headers,
288
+ query_params=query_params,
289
+ _preload_content=_preload_content,
290
+ _request_timeout=_request_timeout,
291
+ body=body)
292
+
293
+ def POST(self, url, headers=None, query_params=None, post_params=None,
294
+ body=None, _preload_content=True, _request_timeout=None):
295
+ """
296
+ HTTP POST
297
+ """
298
+ return self.request("POST", url,
299
+ headers = headers,
300
+ query_params = query_params,
301
+ post_params = post_params,
302
+ _preload_content = _preload_content,
303
+ _request_timeout = _request_timeout,
304
+ body = body)
305
+
306
+ def PUT(self, url, headers = None, query_params = None, post_params = None,
307
+ body=None, _preload_content=True, _request_timeout=None):
308
+ """
309
+ HTTP PUT
310
+ """
311
+ return self.request("PUT", url,
312
+ headers=headers,
313
+ query_params=query_params,
314
+ post_params=post_params,
315
+ _preload_content=_preload_content,
316
+ _request_timeout=_request_timeout,
317
+ body=body)
318
+
319
+ def PATCH(self, url, headers=None, query_params=None, post_params=None,
320
+ body=None, _preload_content=True, _request_timeout=None):
321
+ """
322
+ HTTP PATCH
323
+ """
324
+ return self.request("PATCH", url,
325
+ headers=headers,
326
+ query_params=query_params,
327
+ post_params=post_params,
328
+ _preload_content=_preload_content,
329
+ _request_timeout=_request_timeout,
330
+ body=body)
331
+
332
+
333
+ # end of class RESTClientObject
334
+ def is_ipv4(target):
335
+ """ Test if IPv4 address or not
336
+ """
337
+ try:
338
+ chk = ipaddress.IPv4Address(target)
339
+ return True
340
+ except ipaddress.AddressValueError:
341
+ return False
342
+
343
+
344
+ def in_ipv4net(target, net):
345
+ """ Test if target belongs to given IPv4 network
346
+ """
347
+ try:
348
+ nw = ipaddress.IPv4Network(net)
349
+ ip = ipaddress.IPv4Address(target)
350
+ if ip in nw:
351
+ return True
352
+ return False
353
+ except ipaddress.AddressValueError:
354
+ return False
355
+ except ipaddress.NetmaskValueError:
356
+ return False
357
+
358
+
359
+ def should_bypass_proxies(url, no_proxy=None):
360
+ """ Yet another requests.should_bypass_proxies
361
+ Test if proxies should not be used for a particular url.
362
+ """
363
+
364
+ parsed = urlparse(url)
365
+
366
+ # special cases
367
+ if parsed.hostname in [None, '']:
368
+ return True
369
+
370
+ # special cases
371
+ if no_proxy in [None ,'']:
372
+ return False
373
+ if no_proxy == '*':
374
+ return True
375
+
376
+ no_proxy = no_proxy.lower().replace(' ', '');
377
+ entries = (
378
+ host for host in no_proxy.split(',') if host
379
+ )
380
+
381
+ if is_ipv4(parsed.hostname):
382
+ for item in entries:
383
+ if in_ipv4net(parsed.hostname, item):
384
+ return True
385
+ return proxy_bypass_environment(parsed.hostname, {'no': no_proxy})
bdpan_cli/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version definition for bdpan-cli."""
2
+
3
+ __version__ = "0.1.0"
bdpan_cli/account.py ADDED
@@ -0,0 +1,78 @@
1
+ """账户信息客户端,封装网盘容量与用户信息查询。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from bdpan_cli._vendor import openapi_client
8
+ from bdpan_cli._vendor.openapi_client.api.userinfo_api import UserinfoApi
9
+ from bdpan_cli._vendor.openapi_client.rest import ApiException
10
+
11
+
12
+ class AccountError(RuntimeError):
13
+ """账户信息查询异常。"""
14
+
15
+
16
+ class AccountClient:
17
+ """网盘账户信息封装(容量配额、用户资料)。"""
18
+
19
+ def __init__(self, access_token: str, api_client: openapi_client.ApiClient | None = None) -> None:
20
+ self.access_token = access_token
21
+ self.api_client = api_client or openapi_client.ApiClient()
22
+ self._userinfo_api = UserinfoApi(self.api_client)
23
+
24
+ def quota(self, check_expire: bool = False, check_free: bool = False) -> dict[str, Any]:
25
+ """查询网盘容量配额。
26
+
27
+ Args:
28
+ check_expire: 同时返回会员容量过期时间(expire 字段)。
29
+ check_free: 同时返回免费容量配额(free 字段)。
30
+
31
+ Returns:
32
+ 含 total / used(字节)等键的容量信息字典。
33
+ """
34
+ try:
35
+ res = self._userinfo_api.apiquota(
36
+ access_token=self.access_token,
37
+ checkexpire=1 if check_expire else 0,
38
+ checkfree=1 if check_free else 0,
39
+ )
40
+ except ApiException as e:
41
+ raise AccountError(f"查询网盘容量失败: {e.status} {e.body}") from e
42
+ return self._validate(self._normalize(res), "查询网盘容量")
43
+
44
+ def user_info(self) -> dict[str, Any]:
45
+ """查询网盘用户信息(百度账号、网盘名、会员类型)。
46
+
47
+ Returns:
48
+ 含 baidu_name / netdisk_name / vip_type 等键的用户信息字典。
49
+ """
50
+ try:
51
+ res = self._userinfo_api.xpannasuinfo(access_token=self.access_token)
52
+ except ApiException as e:
53
+ raise AccountError(f"查询用户信息失败: {e.status} {e.body}") from e
54
+ return self._validate(self._normalize(res), "查询用户信息")
55
+
56
+ @staticmethod
57
+ def _normalize(res: Any) -> dict[str, Any]:
58
+ """统一响应形态。
59
+
60
+ UserinfoApi 经 SDK 反序列化返回模型对象而非裸 dict(区别于
61
+ FileinfoApi 等直接返回 dict 的接口),需归一化:优先 to_dict(),
62
+ 退化按 attribute_map 逐键取值。
63
+ """
64
+ if isinstance(res, dict):
65
+ return res
66
+ to_dict = getattr(res, "to_dict", None)
67
+ if callable(to_dict):
68
+ return to_dict()
69
+ attr_map = getattr(res, "attribute_map", None)
70
+ if isinstance(attr_map, dict):
71
+ return {key: getattr(res, key, None) for key in attr_map}
72
+ raise AccountError(f"无法解析的响应类型: {type(res).__name__}")
73
+
74
+ @staticmethod
75
+ def _validate(res: dict[str, Any], ctx: str) -> dict[str, Any]:
76
+ if res.get("errno") not in (0, None):
77
+ raise AccountError(f"{ctx}返回错误: errno={res.get('errno')} ({res})")
78
+ return res
@@ -0,0 +1,99 @@
1
+ """依据审核后的 scrape_preview.json 执行网盘目录批量重命名。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from bdpan_cli.auth import TokenManager
8
+ from bdpan_cli.config import load_config
9
+ from bdpan_cli.file_manager import FileManager
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ ROOT_UNWATCHED = "/影视/电影/unwatched"
14
+
15
+
16
+ def apply_renames(
17
+ preview_file: str = "scrape_preview.json",
18
+ target_dir: str | None = None,
19
+ batch_size: int = 50,
20
+ ) -> None:
21
+ config = load_config()
22
+ token = TokenManager(config).get_access_token()
23
+ mgr = FileManager(token)
24
+
25
+ with open(preview_file, "r", encoding="utf-8") as f:
26
+ mapping = json.load(f)
27
+
28
+ verify_dir = target_dir or (mapping[0].get("parent_dir") if mapping else None) or ROOT_UNWATCHED
29
+ logger.info("正在查询网盘当前 %s 目录下实际存在的条目...", verify_dir)
30
+ current_entries = mgr.list_dir(verify_dir)
31
+ current_names = set(e.get("server_filename") for e in current_entries)
32
+ logger.info("网盘当前 %s 目录下实际存在 %d 个条目", verify_dir, len(current_names))
33
+
34
+ rename_items: list[tuple[str, str]] = []
35
+ skipped_items: list[str] = []
36
+
37
+ for item in mapping:
38
+ idx = item["index"]
39
+ raw = item["raw_name"]
40
+ target = item["target_name"]
41
+ status = item.get("status")
42
+
43
+ if status != "OK" or not target or target.startswith("【"):
44
+ logger.warning("[%d] 跳过未匹配或跳过项 (%s): %s", idx, status, raw)
45
+ skipped_items.append(raw)
46
+ continue
47
+
48
+ if raw == target or target in current_names:
49
+ logger.info("[%d] 目标名称已符合规范或已存在,无需更名: %s", idx, target)
50
+ skipped_items.append(raw)
51
+ continue
52
+
53
+ if raw not in current_names:
54
+ logger.warning("[%d] 源目录在网盘中不存在,跳过: %s", idx, raw)
55
+ skipped_items.append(raw)
56
+ continue
57
+
58
+ src_path = item.get("src_path") or f"{verify_dir}/{raw}"
59
+ rename_items.append((src_path, target))
60
+ logger.info("[%d] 计划更名: %s -> %s", idx, raw, target)
61
+
62
+ if not rename_items:
63
+ logger.info("没有需要更名的目录,操作完成。")
64
+ return
65
+
66
+ logger.info("准备对 %d 个目录执行批量更名(每批 %d 项)...", len(rename_items), batch_size)
67
+ results = mgr.batch_rename(rename_items, batch_size=batch_size)
68
+
69
+ # 统计成功与失败
70
+ success_count = 0
71
+ fail_count = 0
72
+ for r in results:
73
+ info_list = r.get("info", [])
74
+ for inf in info_list:
75
+ if inf.get("errno") == 0:
76
+ success_count += 1
77
+ else:
78
+ fail_count += 1
79
+ logger.error("更名失败项: %s", inf)
80
+
81
+ logger.info("=== 批量更名执行完毕: 提交 %d, 成功 %d, 失败 %d, 跳过 %d ===", len(rename_items), success_count, fail_count, len(skipped_items))
82
+
83
+ # 执行对账
84
+ logger.info("=== 执行网盘目录更名后对账 ===")
85
+ reconciled_entries = mgr.list_dir(verify_dir)
86
+ logger.info("当前 %s 下共有 %d 个目录:", verify_dir, len(reconciled_entries))
87
+ for i, e in enumerate(reconciled_entries, 1):
88
+ logger.info(" %2d. %s", i, e.get("server_filename"))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ import argparse
93
+ parser = argparse.ArgumentParser(description="执行批量更名")
94
+ parser.add_argument("--preview", default="watched_scrape_preview.json", help="预演映射文件")
95
+ parser.add_argument("--dir", default=None, help="目标目录(可选,若未在 json 中指定)")
96
+ parser.add_argument("--batch-size", type=int, default=25, help="每批重命名数量")
97
+ args = parser.parse_args()
98
+ apply_renames(args.preview, target_dir=args.dir, batch_size=args.batch_size)
99
+