.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | - cloud189-cli -
2 |
3 | # /$$$$$$ /$$ /$$ /$$ /$$$$$$ /$$$$$$
4 | # /$$__ $$| $$ | $$ /$$$$ /$$__ $$ /$$__ $$
5 | # | $$ \__/| $$ /$$$$$$ /$$ /$$ /$$$$$$$|_ $$ | $$ \ $$| $$ \ $$
6 | # | $$ | $$ /$$__ $$| $$ | $$ /$$__ $$ | $$ | $$$$$$/| $$$$$$$
7 | # | $$ | $$| $$ \ $$| $$ | $$| $$ | $$ | $$ >$$__ $$ \____ $$
8 | # | $$ $$| $$| $$ | $$| $$ | $$| $$ | $$ | $$ | $$ \ $$ /$$ \ $$
9 | # | $$$$$$/| $$| $$$$$$/| $$$$$$/| $$$$$$$ /$$$$$$| $$$$$$/| $$$$$$/
10 | # \______/ |__/ \______/ \______/ \_______/|______/ \______/ \______/
11 | #
12 | --------------------------------------------------------------------------
13 |
14 |
15 |
16 |
17 |
18 | # 准备
19 | 1. Python 版本 >= 3.8
20 |
21 | 2. 安装依赖
22 | ```sh
23 | pip install -r requirements.txt
24 | ```
25 | > 注意 `pyreadline` 是专门为 `Windows` 设计的,`*nix` 中 python 标准库中一般默认包含 `readline` 模块,没有请看[这里](#jump)。
26 |
27 | 3. 配置
28 | 运行 ``python main.py``, 输入用户名与密码,
29 | 账号为自己的天翼云盘手机号,密码不会有回显,
30 | 也可以 直接两次回车后,输入 `clogin` 按提示输入 `cookie` 登录。
31 | 所有信息**加密** 保存至 `.config` 文件。
32 |
33 | # 功能
34 |
35 | |命令 |描述 |
36 | |-------------------------------------|-----------------------|
37 | |help |查看帮助文档 |
38 | |login |用户名+密码 登录/添加用户 |
39 | |clogin |cookie 登录/添加用户 |
40 | |refresh |刷新当前目录 |
41 | |setpath |修改下载路径(默认 ./downloads) |
42 | |update |检测软件更新 |
43 | |who/quota |查看账户信息、空间大小 |
44 | |clear |清屏 |
45 | |cdrec |进入回收站 |
46 | |[cdrec] ls |显示回收站目录 |
47 | |[cdrec] rec + `文件名` |恢复文件 |
48 | |[cdrec] clean |清空回收站 |
49 | |[cdrec] cd .. |退出回收站 |
50 | |su + `[-l/用户名]` |列出用户/切换用户 |
51 | |ls + `[-l] [文件夹]` |列出文件与目录 |
52 | |cd + `文件夹名` |切换工作目录 |
53 | |upload + `文件(夹)路径` |上传文件(夹) |
54 | |down + `文件名/分享链接` |下载文件/提取分享链接下载直链 |
55 | |mkdir + `文件夹名` |创建文件夹 |
56 | |rm + `文件/文件夹` |删除文件(夹) |
57 | |share + `文件/文件夹` |分享文件(夹) |
58 | |shared + `[2]` |已经分享文件(夹)信息 |
59 | |jobs + `[-f] [任务id]` |查看后台上传下载任务 |
60 | |rename + `文件(夹)名 [新名]` |重命名 |
61 | |mv + `文件名` |移动文件 |
62 | |sign + `[-a/--all]` |签到抽奖 |
63 | |bye/exit |退出 |
64 |
65 | 详细请移步 [Wiki](https://github.com/Aruelius/cloud189/wiki).
66 |
67 | `ll = ls -l` 表示列出详细列表,`ls` 只显示文件(夹)名,都可以接一个一级子文件夹作为参数。
68 | `down`、`upload`、`rm` 支持多个多个操作文件作为参数,如果文件名中有空格引号,使用 `''`、`""` 包裹文件名,或则在空格引号前使用转义符 `\`。
69 | `jobs -f`、`upload -f`、`down -f`表示实时查看任务状态,类似于 `Linux` 中的 `tail -f`,按任意键 + 回车 退出。
70 | 使用账号密码登录时,上传文件时会**先**进行文件秒传检测,目前使用 cookie 登录无法秒传。
71 | 下载支持断点续传。
72 | 注意:从 **v0.0.4** 起,`.config` 文件与以前版本不兼容!
73 |
74 | # 使用
75 | 1. 不加参数则进入交互模式
76 | ```sh
77 | # 提示符为 >
78 | python3 main.py
79 | > cd '文件夹'
80 | ...
81 | > ls
82 | ...
83 | > bye
84 | ```
85 |
86 | 2. 带上命令与参数进入单任务模式
87 | ```sh
88 | python3 main.py upload '文件路径'
89 | # 或者
90 | ./main.py upload '文件路径'
91 | ```
92 |
93 | # 依赖
94 | 如果在 Linux 运行出现
95 | ~~~shell
96 | import readline
97 | ValueError: _type_ 'v' not supported
98 | ~~~
99 | 需要安装依赖,然后重新编译 Python
100 | Ubuntu
101 | ~~~shell
102 | sudo apt-get install libreadline-dev
103 | ~~~
104 | CentOS
105 | ~~~shell
106 | yum install readline-devel
107 | ~~~
108 | # License
109 |
110 | [GPL-3.0](https://github.com/Aruelius/cloud189/blob/master/LICENSE)
111 |
112 | # 致谢
113 |
114 | > [LanZouCloud-CMD](https://github.com/zaxtyson/LanZouCloud-CMD)
115 | > [Dawnnnnnn/Cloud189](https://github.com/Dawnnnnnn/Cloud189)
116 |
--------------------------------------------------------------------------------
/cloud189/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ['api', 'cli']
2 |
--------------------------------------------------------------------------------
/cloud189/api/__init__.py:
--------------------------------------------------------------------------------
1 | from cloud189.api.core import Cloud189
2 |
3 | version = '0.0.5'
4 |
5 | __all__ = ['utils', 'Cloud189', 'models', 'token', 'version']
6 |
--------------------------------------------------------------------------------
/cloud189/api/core.py:
--------------------------------------------------------------------------------
1 | """
2 | 天翼云盘 API,封装了对天翼云的各种操作
3 | """
4 |
5 | import os
6 | import re
7 | import json
8 | import simplejson
9 | from time import sleep
10 |
11 | from xml.etree import ElementTree
12 | import requests
13 | from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
14 | from urllib3 import disable_warnings
15 | from urllib3.exceptions import InsecureRequestWarning
16 |
17 | from cloud189.api.utils import *
18 | from cloud189.api.types import *
19 | from cloud189.api.models import *
20 |
21 | __all__ = ['Cloud189']
22 |
23 |
24 | class Cloud189(object):
25 | FAILED = -1
26 | SUCCESS = 0
27 | ID_ERROR = 1
28 | PASSWORD_ERROR = 2
29 | LACK_PASSWORD = 3
30 | MKDIR_ERROR = 5
31 | URL_INVALID = 6
32 | FILE_CANCELLED = 7
33 | PATH_ERROR = 8
34 | NETWORK_ERROR = 9
35 | CAPTCHA_ERROR = 10
36 | UP_COMMIT_ERROR = 4 # 上传文件 commit 错误
37 | UP_CREATE_ERROR = 11 # 创建上传任务出错
38 | UP_UNKNOWN_ERROR = 12 # 创建上传任务未知错误
39 | UP_EXHAUSTED_ERROR = 13 # 上传量用完
40 | UP_ILLEGAL_ERROR = 14 # 文件非法
41 |
42 | def __init__(self):
43 | self._session = requests.Session()
44 | self._captcha_handler = None
45 | self._timeout = 15 # 每个请求的超时(不包含下载响应体的用时)
46 | self._host_url = 'https://cloud.189.cn'
47 | self._auth_url = 'https://open.e.189.cn/api/logbox/oauth2/'
48 | self._cookies = None
49 | self._sessionKey = ""
50 | self._sessionSecret = ""
51 | self._accessToken = ""
52 | self._headers = {
53 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/76.0',
54 | 'Referer': 'https://open.e.189.cn/',
55 | 'Accept': 'application/json;charset=UTF-8',
56 | }
57 | disable_warnings(InsecureRequestWarning) # 全局禁用 SSL 警告
58 |
59 | def _get(self, url, **kwargs):
60 | try:
61 | kwargs.setdefault('timeout', self._timeout)
62 | kwargs.setdefault('headers', self._headers)
63 | return self._session.get(url, verify=False, **kwargs)
64 | except requests.Timeout:
65 | logger.warning(
66 | "Encountered timeout error while requesting network!")
67 | raise TimeoutError
68 | except (requests.RequestException, Exception) as e:
69 | logger.error(f"Unexpected error: {e=}")
70 |
71 | def _post(self, url, data, **kwargs):
72 | try:
73 | kwargs.setdefault('timeout', self._timeout)
74 | kwargs.setdefault('headers', self._headers)
75 | return self._session.post(url, data, verify=False, **kwargs)
76 | except requests.Timeout:
77 | logger.warning(
78 | "Encountered timeout error while requesting network!")
79 | raise TimeoutError
80 | except (requests.RequestException, Exception) as e:
81 | logger.error(f"Unexpected error: {e=}")
82 |
83 | def set_session(self, key, secret, token):
84 | self._sessionKey = key
85 | self._sessionSecret = secret
86 | self._accessToken = token
87 |
88 | def set_captcha_handler(self, captcha_handler):
89 | """设置下载验证码处理函数
90 | :param captcha_handler (img_data) -> str 参数为图片二进制数据,需返回验证码字符
91 | """
92 | self._captcha_handler = captcha_handler
93 |
94 | def get_cookie(self):
95 | return self._session.cookies.get_dict()
96 |
97 | def _needcaptcha(self, captchaToken, username):
98 | """登录验证码处理函数"""
99 | url = self._auth_url + "needcaptcha.do"
100 | post_data = {
101 | "accountType": "01",
102 | "userName": "{RSA}" + b64tohex(encrypt(username)),
103 | "appKey": "cloud"
104 | }
105 | r = self._post(url, data=post_data)
106 | captcha = ""
107 | if r.text != "0": # 需要验证码
108 | if self._captcha_handler:
109 | pic_url = self._auth_url + "picCaptcha.do"
110 | img_data = self._get(
111 | pic_url, params={"token": captchaToken}).content
112 | captcha = self._captcha_handler(img_data) # 用户手动识别验证码
113 | else:
114 | logger.error("No verification code processing function!")
115 | return captcha
116 |
117 | def login_by_cookie(self, config):
118 | """使用 cookie 登录"""
119 | cookies = config if isinstance(config, dict) else config.cookie
120 | try:
121 | for k, v in cookies.items():
122 | self._session.cookies.set(k, v, domain=".cloud.189.cn")
123 | resp = self._get(self._host_url + "/v2/getUserLevelInfo.action")
124 | if "InvalidSessionKey" not in resp.text:
125 | try:
126 | self.set_session(config.key, config.secret, config.token)
127 | except:
128 | pass
129 | return Cloud189.SUCCESS
130 | except:
131 | pass
132 | return Cloud189.FAILED
133 |
134 | def login(self, username, password):
135 | """使用 用户名+密码 登录"""
136 | url = self._host_url + "/api/portal/loginUrl.action"
137 | params = {"pageId": 1, "redirectURL": "https://cloud.189.cn/main.action"}
138 | resp = self._get(url, params=params)
139 | if not resp:
140 | logger.error("redirect error!")
141 | return Cloud189.NETWORK_ERROR
142 | # captchaToken = re.search(r"captchaToken' value='(.+?)'", resp.text)
143 | captchaToken = re.search(r"captchaToken\W*value=\W*(\w*)", resp.text)
144 | # returnUrl = re.search(r"returnUrl = '(.+?)'", resp.text)
145 | returnUrl = re.search(r"returnUrl =\W*([^'\"]*)", resp.text)
146 | # paramId = re.search(r'paramId *=\W*(\w*)', resp.text)
147 | paramId = re.search(r'paramId =\W*(\w*)', resp.text)
148 | # lt = re.search(r'lt = "(.+?)"', resp.text)
149 | lt = re.search(r'lt =\W+(\w*)', resp.text)
150 |
151 | captchaToken = captchaToken.group(1) if captchaToken else ""
152 | lt = lt.group(1) if lt else ""
153 | returnUrl = returnUrl.group(1) if returnUrl else ""
154 | paramId = paramId.group(1) if paramId else ""
155 | logger.debug(f"Login: {captchaToken=}, {lt=}, {returnUrl=}, {paramId=}")
156 | self._session.headers.update({"lt": lt})
157 |
158 | validateCode = self._needcaptcha(captchaToken, username)
159 | url = self._auth_url + "loginSubmit.do"
160 | data = {
161 | "appKey": "cloud",
162 | "accountType": '01',
163 | "userName": "{RSA}" + b64tohex(encrypt(username)),
164 | "password": "{RSA}" + b64tohex(encrypt(password)),
165 | "validateCode": validateCode,
166 | "captchaToken": captchaToken,
167 | "returnUrl": returnUrl,
168 | "mailSuffix": "@189.cn",
169 | "paramId": paramId
170 | }
171 | r = self._post(url, data=data)
172 | msg = r.json()["msg"]
173 | if msg == "登录成功":
174 | self._get(r.json()["toUrl"])
175 | return Cloud189.SUCCESS
176 | print(msg)
177 | return Cloud189.FAILED
178 |
179 | def _get_root_more_page(self, resp: dict, r_path=False) -> (list, bool):
180 | """处理可能需要翻页的请求信息"""
181 | if resp['pageNum'] * resp['pageSize'] >= resp['recordCount']:
182 | done = True # 没有了
183 | else:
184 | done = False
185 | if r_path:
186 | return [resp['data'], resp['path']], done
187 | else:
188 | return resp['data'], done
189 |
190 | def _get_more_page(self, resp: dict, pageNum=1, pageSize=60) -> (bool):
191 | """处理可能需要翻页的请求信息"""
192 | return pageNum * pageSize >= resp['count']
193 |
194 | def get_rec_file_list(self) -> FileList:
195 | """获取回收站文件夹列表"""
196 | results = FileList()
197 | page = 1
198 | data = []
199 | url = self._host_url + '/v2/listRecycleBin.action'
200 |
201 | while True:
202 | resp = self._get(url, params={'pageNum': page, 'pageSize': 60})
203 | if not resp:
204 | logger.error("Rec file list: network error!")
205 | return None
206 | resp = resp.json()
207 | familyId = resp['familyId']
208 | data_, done = self._get_more_page(resp)
209 | data.extend(data_)
210 | if done:
211 | break
212 | page += 1
213 | sleep(0.5) # 大量请求可能会被限制
214 |
215 | for item in data:
216 | name = item['fileName']
217 | id_ = item['fileId']
218 | pid = item['parentId']
219 | ctime = item['createTime']
220 | optime = item['lastOpTime']
221 | size = item['fileSize']
222 | ftype = item['fileType']
223 | durl = item['downloadUrl']
224 | isFolder = item['isFolder']
225 | isFamily = item['isFamilyFile']
226 | path = item['pathStr']
227 | results.append(RecInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size,
228 | ftype=ftype, isFolder=isFolder, durl=durl, isFamily=isFamily, path=path,
229 | fid=familyId))
230 | return results
231 |
232 | def _batch_task(self, file_info, action: str, target_id: str = '') -> int:
233 | """公共批处理请求
234 | :param file_info: FolderInfo、RecInfo、RecInfo
235 | :param action: RESTORE、DELETE、MOVE、COPY
236 | :param target_id: 移动文件的目标文件夹 id
237 | :return: Cloud189 状态码
238 | """
239 | task_info = {
240 | "fileId": str(file_info.id), # str
241 | "srcParentId": str(file_info.pid), # str
242 | "fileName": file_info.name, # str
243 | "isFolder": 1 if file_info.isFolder else 0 # int
244 | }
245 |
246 | create_url = self._host_url + "/createBatchTask.action"
247 | post_data = {"type": action, "taskInfos": json.dumps([task_info, ])}
248 | if target_id:
249 | post_data.update({"targetFolderId": target_id})
250 | resp = self._post(create_url, data=post_data)
251 | task_id = resp.text.strip('"').strip('\'')
252 | logger.debug(
253 | f"Text: {resp.text=}, {task_id=}, {action=}, {target_id=}")
254 | if not task_id:
255 | logger.debug(f"Batch_task: {resp.status_code=}")
256 | return Cloud189.FAILED
257 |
258 | def _check_task(task_id):
259 | check_url = self._host_url + '/checkBatchTask.action'
260 | post_data = {"type": action, "taskId": task_id}
261 | resp = self._post(check_url, data=post_data)
262 | if not resp:
263 | logger.debug("BatchTask[_check] Error!")
264 | resp = resp.json()
265 | if 'taskStatus' in resp:
266 | return resp['taskStatus']
267 | else:
268 | logger.debug(
269 | f"BatchTask[_check]: {post_data=},{task_id=},{resp=}")
270 | return 5 # 防止无限循环
271 |
272 | task_status = 0
273 | while task_status != 4:
274 | sleep(0.5)
275 | task_status = _check_task(task_id)
276 | return Cloud189.SUCCESS
277 |
278 | def rec_restore(self, file_info):
279 | """还原文件"""
280 | return self._batch_task(file_info, 'RESTORE')
281 |
282 | def rec_delete(self, file_info):
283 | """回收站删除文件"""
284 | url = self._host_url + '/v2/deleteFile.action'
285 | resp = self._get(
286 | url, params={'familyId': file_info.fid, 'fileIdList': file_info.id})
287 | if resp and resp.json()['success']:
288 | return Cloud189.SUCCESS
289 | else:
290 | return Cloud189.FAILED
291 |
292 | def rec_empty(self, file_info):
293 | """清空回收站"""
294 | url = self._host_url + '/v2/emptyRecycleBin.action'
295 | resp = self._get(url, params={'familyId': file_info.fid})
296 | if resp and resp.json()['success']:
297 | return Cloud189.SUCCESS
298 | else:
299 | return Cloud189.FAILED
300 |
301 | def share_file(self, fid, et=None, ac=None):
302 | '''分享文件'''
303 | expireTime_dict = {"1": "1", "2": "7", "3": "2099"}
304 | if et and et in ('1', '2', '3'):
305 | expireTime = et
306 | else:
307 | expireTime = input("请选择分享有效期:1、1天,2、7天,3、永久:")
308 | if ac and ac in ('1', '2'):
309 | withAccessCode = ac
310 | else:
311 | withAccessCode = input("请选择分享形式:1、私密分享,2、公开分享:")
312 | if withAccessCode == "1":
313 | url = self._host_url + "/v2/privateLinkShare.action"
314 | params = {
315 | "fileId": str(fid),
316 | "expireTime": expireTime_dict[expireTime],
317 | "withAccessCode": withAccessCode
318 | }
319 | else:
320 | url = self._host_url + "/v2/createOutLinkShare.action"
321 | params = {
322 | "fileId": str(fid),
323 | "expireTime": expireTime_dict[expireTime]
324 | }
325 | resp = self._get(url=url, params=params)
326 | if not resp:
327 | logger.error(f"Share file: {fid=}network error!")
328 | return ShareCode(Cloud189.FAILED)
329 | resp = resp.json()
330 | share_url = resp['shortShareUrl']
331 | pwd = resp['accessCode'] if 'accessCode' in resp else ''
332 | return ShareCode(Cloud189.SUCCESS, share_url, pwd, expireTime)
333 |
334 | def get_root_file_list(self) -> (FileList, PathList):
335 | """获取根目录下文件列表的方法"""
336 | fid = -11
337 | file_list = FileList()
338 | path_list = PathList()
339 | page = 1
340 | data_path = []
341 | data = []
342 | path = []
343 | url = self._host_url + "/api/portal/listFiles.action"
344 | while True:
345 | params = {
346 | "fileId": str(fid),
347 | "noCache": "0.9551043190321311"
348 | }
349 | resp = self._get(url, params=params)
350 | if not resp:
351 | logger.error(f"File list: {fid=}network error!")
352 | return file_list, path_list
353 | if not resp:
354 | logger.error(f"File list: {fid=}network error!")
355 | return file_list, path_list
356 | try:
357 | resp = resp.json()
358 | except (json.JSONDecodeError, simplejson.errors.JSONDecodeError):
359 | # 如果 fid 文件夹被删掉,resp 是 200 但是无法使用 json 方法
360 | logger.error(f"File list: {fid=} not exit")
361 | return file_list, path_list
362 | if 'errorCode' in resp:
363 | logger.error(f"Get file: {resp}")
364 | return file_list, path_list
365 | data_, done = self._get_root_more_page(resp, r_path=True)
366 | data_path.append(data_)
367 | if done:
368 | break
369 | page += 1
370 | sleep(0.5) # 大量请求可能会被限制
371 | for data_ in data_path:
372 | data.extend(data_[0])
373 | if not path:
374 | path = data_[1] # 不同 page 路径应该是一样的
375 | for item in data:
376 | name = item['fileName']
377 | id_ = int(item['fileId'])
378 | pid = int(item['parentId'])
379 | ctime = item['createTime']
380 | optime = item['lastOpTime']
381 | size = item['fileSize'] if 'fileSize' in item else ''
382 | ftype = item['fileType']
383 | durl = item['downloadUrl'] if 'downloadUrl' in item else ''
384 | isFolder = item['isFolder']
385 | isStarred = item['isStarred']
386 | file_list.append(FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size,
387 | ftype=ftype, durl=durl, isFolder=isFolder, isStarred=isStarred))
388 | for item in path:
389 | path_list.append(PathInfo(name=item['fileName'], id=int(item['fileId']),
390 | isCoShare=item['isCoShare']))
391 |
392 | return file_list, path_list
393 |
394 |
395 | def get_file_list(self, fid) -> (FileList):
396 | """获取文件列表"""
397 | file_list = FileList()
398 | path_list = PathList()
399 | page = 1
400 | data = []
401 | url = self._host_url + "/api/open/file/listFiles.action"
402 | while True:
403 | params = {
404 | "folderId": str(fid),
405 | "orderBy": "lastOpTime",
406 | "descending": "true",
407 | "pageNum": page,
408 | "pageSize": 60,
409 | "iconOption": 5,
410 | "mediaType": 0,
411 | "noCache": "0.10860476256694767"
412 | }
413 | resp = self._get(url, params=params)
414 | if not resp:
415 | logger.error(f"File list: {fid=}network error!")
416 | return file_list, path_list
417 | try:
418 | resp = resp.json()
419 | except (json.JSONDecodeError, simplejson.errors.JSONDecodeError):
420 | # 如果 fid 文件夹被删掉,resp 是 200 但是无法使用 json 方法
421 | logger.error(f"File list: {fid=} not exit")
422 | return file_list, path_list
423 | if 'errorCode' in resp:
424 | logger.error(f"Get file: {resp}")
425 | return file_list, path_list
426 | resp = resp["fileListAO"]
427 | done = self._get_more_page(resp, pageNum=page, pageSize=60)
428 | data.extend(resp["folderList"])
429 | data.extend(resp["fileList"])
430 | if done:
431 | break
432 | page += 1 # 继续循环处理翻页
433 | sleep(0.5) # 大量请求可能会被限制
434 |
435 | for item in data:
436 | name = item['name']
437 | id_ = int(item['id'])
438 | pid = int(item['parentId']) if 'parentId' in item else ''
439 | ctime = item['createDate']
440 | optime = item['lastOpTime']
441 | size = item['size'] if 'size' in item else ''
442 | ftype = ''
443 | durl = ''
444 | isFolder = 'fileCount' in item
445 | isStarred = ''
446 |
447 | file_list.append(FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size,
448 | ftype=ftype, durl=durl, isFolder=isFolder, isStarred=isStarred))
449 |
450 | return file_list, self.get_file_path_list(fid)
451 |
452 | def get_file_path_list(self, fid) -> (PathList):
453 | path_list = PathList()
454 | travel_id = fid
455 | while True:
456 | code, cur_file_info = self.get_file_info_by_id(travel_id)
457 | if code != Cloud189.SUCCESS:
458 | logger.error(f"Get File{fid} info error.")
459 | return None
460 | path_list.insert(0, PathInfo(name=cur_file_info.name, id=cur_file_info.id, isCoShare=0))
461 | travel_id = cur_file_info.pid
462 | if travel_id == -11:
463 | # 根目录无法通过查询文件信息查询,会返回400,因此直接拼装
464 | path_list.insert(0, PathInfo(name='全部文件', id=-11, isCoShare=0))
465 | break
466 | sleep(0.2)
467 |
468 | return path_list
469 |
470 | def _create_upload_file(self, up_info: UpInfo) -> (int, tuple):
471 | """创建上传任务,包含秒传检查,返回状态码、创建信息"""
472 | infos = tuple()
473 | try:
474 | url = API + "/createUploadFile.action?{SUFFIX_PARAM}"
475 | date = get_time()
476 | headers = {
477 | "SessionKey": self._sessionKey,
478 | "Sign-Type": "1",
479 | "User-Agent": UA,
480 | "Date": date,
481 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'POST', url, date),
482 | "Accept": "application/json;charset=UTF-8",
483 | "Content-Type": "application/x-www-form-urlencoded",
484 | }
485 | post_data = {
486 | "parentFolderId": up_info.fid,
487 | "baseFileId": "",
488 | "fileName": up_info.name,
489 | "size": up_info.size,
490 | "md5": get_file_md5(up_info.path, up_info.check),
491 | "lastWrite": "",
492 | "localPath": up_info.path,
493 | "opertype": 1,
494 | "flag": 1,
495 | "resumePolicy": 1,
496 | "isLog": 0
497 | }
498 | resp = requests.post(url, headers=headers, data=post_data, verify=False, timeout=10)
499 | if resp:
500 | resp = resp.json()
501 | if resp['res_message'] == "UserDayFlowOverLimited":
502 | _msg = f"{up_info.path=}, The daily transmission of the current login account has been exhausted!"
503 | code = Cloud189.UP_EXHAUSTED_ERROR
504 | elif resp.get('res_code') == 'InfoSecurityErrorCode':
505 | _msg = f"{up_info.path=} Illegal file!"
506 | code = Cloud189.UP_ILLEGAL_ERROR
507 | elif resp.get('uploadFileId'):
508 | upload_file_id = resp['uploadFileId']
509 | file_upload_url = resp['fileUploadUrl']
510 | file_commit_url = resp['fileCommitUrl']
511 | file_data_exists = resp['fileDataExists']
512 | node = file_upload_url.split('//')[1].split('.')[0]
513 | _msg = f"successfully created upload task, {node=}"
514 | infos = (upload_file_id, file_upload_url, file_commit_url, file_data_exists)
515 | code = Cloud189.SUCCESS
516 | else:
517 | _msg = f"unknown response {resp=}. Please contact the developer!"
518 | code = Cloud189.UP_UNKNOWN_ERROR
519 | logger.debug(f'Upload by client [create]: {_msg}')
520 | else:
521 | code = Cloud189.NETWORK_ERROR
522 | except Exception as e:
523 | code = Cloud189.UP_CREATE_ERROR
524 | logger.error(f'Upload by client [create]: an error occurred! {e=}')
525 | return code, infos
526 |
527 | def _upload_file_data(self, file_upload_url, upload_file_id, up_info: UpInfo):
528 | """客户端接口上传文件数据"""
529 | url = f"{file_upload_url}?{SUFFIX_PARAM}"
530 | date = get_time()
531 | headers = {
532 | "SessionKey": self._sessionKey,
533 | "Edrive-UploadFileId": str(upload_file_id),
534 | "User-Agent": UA,
535 | "Date": date,
536 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'PUT', url, date),
537 | "Accept": "application/json;charset=UTF-8",
538 | "Content-Type": "application/octet-stream",
539 | "Edrive-UploadFileRange": f"0-{up_info.size}",
540 | "ResumePolicy": "1"
541 | }
542 |
543 | self._upload_finished_flag = False # 上传完成的标志
544 |
545 | def _call_back(it, chunk_size):
546 | for chunk_now, item in enumerate(it):
547 | yield item
548 | if up_info.callback:
549 | now_size = chunk_now * chunk_size
550 | if not self._upload_finished_flag:
551 | up_info.callback(up_info.path, up_info.size, now_size)
552 | if now_size == up_info.size:
553 | self._upload_finished_flag = True
554 | if up_info.callback: # 保证迭代完后,两者大小一样
555 | up_info.callback(up_info.path, up_info.size, up_info.size)
556 |
557 | chunk_size = get_chunk_size(up_info.size)
558 | with open(up_info.path, 'rb') as f:
559 | chunks = get_upload_chunks(f, chunk_size)
560 | post_data = _call_back(chunks, chunk_size)
561 |
562 | resp = requests.put(url, data=post_data, headers=headers, verify=False, timeout=None)
563 | if resp.text != "":
564 | node = ElementTree.XML(resp.text)
565 | if node.text == "error":
566 | if node.findtext('code') != 'UploadFileCompeletedError':
567 | logger.error(
568 | f"Upload by client [data]: an error occurred while uploading data {node.findtext('code')},{node.findtext('message')}")
569 | return Cloud189.FAILED
570 | else:
571 | logger.debug(f"Upload by client [data]: upload {up_info.path} success!")
572 | return Cloud189.SUCCESS
573 |
574 | def _upload_client_commit(self, file_commit_url, upload_file_id):
575 | """客户端接口上传确认"""
576 | fid = ''
577 | try:
578 | url = f"{file_commit_url}?{SUFFIX_PARAM}"
579 | date = get_time() # 时间戳
580 | headers = {
581 | "SessionKey": self._sessionKey,
582 | "User-Agent": UA,
583 | "Date": date,
584 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'POST', url, date),
585 | "Accept": "application/json;charset=UTF-8",
586 | "Content-Type": "application/x-www-form-urlencoded",
587 | }
588 | post_data = {
589 | "uploadFileId": upload_file_id,
590 | "opertype": 1,
591 | "isLog": 0,
592 | "ResumePolicy": 1
593 | }
594 | resp = requests.post(url, data=post_data, headers=headers, verify=False, timeout=10)
595 | node = ElementTree.XML(resp.text)
596 | if node.text != 'error':
597 | fid = node.findtext('id')
598 | fname = node.findtext('name')
599 | time = node.findtext('createDate')
600 | logger.debug(f"Upload by client [commit]: at[{time}] upload [{fname}], {fid=} success!")
601 | else:
602 | logger.error(f'Upload by client [commit]: unknown error {resp.text=}')
603 | except Exception as e:
604 | logger.error(f'Upload by client [commit]: an error occurred! {e=}')
605 | return fid
606 |
607 | def _upload_file_by_client(self, up_info: UpInfo) -> UpCode:
608 | """使用客户端接口上传单文件,支持秒传功能
609 | :param up_info: UpInfo
610 | :return: UpCode
611 | """
612 | if up_info.callback and up_info.check:
613 | up_info.callback(up_info.path, 1, 0, 'check')
614 | quick_up = False
615 | fid = ''
616 | code, infos = self._create_upload_file(up_info)
617 | if code == Cloud189.SUCCESS:
618 | upload_file_id, file_upload_url, file_commit_url, file_data_exists = infos
619 | if file_data_exists == 1: # 数据存在,进入秒传流程
620 | logger.debug(f"Upload by client: [{up_info.path}] enter the quick_up process...")
621 | fid = self._upload_client_commit(file_commit_url, upload_file_id)
622 | if fid:
623 | call_back_msg = 'quick_up'
624 | quick_up = True
625 | else:
626 | call_back_msg = 'error'
627 | code = Cloud189.UP_COMMIT_ERROR
628 | else: # 上传文件数据
629 | logger.debug(f"Upload by client: [{up_info.path}] enter the normal upload process...")
630 | code = self._upload_file_data(file_upload_url, upload_file_id, up_info)
631 | if code == Cloud189.SUCCESS:
632 | call_back_msg = None
633 | fid = self._upload_client_commit(file_commit_url, upload_file_id)
634 | else:
635 | call_back_msg = 'error'
636 | logger.debug(f"Upload by client: [{up_info.path}] normal upload failed!")
637 | elif code == Cloud189.UP_ILLEGAL_ERROR:
638 | call_back_msg = 'illegal'
639 | elif code == Cloud189.UP_EXHAUSTED_ERROR:
640 | call_back_msg = 'exhausted'
641 | else:
642 | call_back_msg = 'error'
643 |
644 | if up_info.callback and call_back_msg:
645 | up_info.callback(up_info.path, 1, 1, call_back_msg)
646 | return UpCode(code=code, id=fid, quick_up=quick_up, path=up_info.path)
647 |
648 | def _upload_file_by_web(self, up_info: UpInfo) -> UpCode:
649 | """使用网页接口上传单文件,不支持秒传
650 | :param up_info: UpInfo
651 | :return: UpCode
652 | """
653 | headers = {'Referer': self._host_url}
654 | url = self._host_url + "/v2/getUserUploadUrl.action"
655 | resp = self._get(url, headers=headers)
656 | if not resp:
657 | logger.error(f"Upload by web: [{up_info.path}] network error(1)!")
658 | if up_info.callback:
659 | up_info.callback(up_info.path, 1, 1, 'error')
660 | return UpCode(code=Cloud189.NETWORK_ERROR, path=up_info.path)
661 | resp = resp.json()
662 | if 'uploadUrl' in resp:
663 | upload_url = "https:" + resp['uploadUrl']
664 | else:
665 | logger.error(f"Upload by web: [{up_info.path}] failed to obtain upload node!")
666 | upload_url = ''
667 |
668 | self._session.headers["Referer"] = self._host_url # 放到 headers?
669 |
670 | headers.update({"Host": "cloud.189.cn"})
671 | url = self._host_url + "/main.action"
672 | resp = self._get(url, headers=headers)
673 | if not resp:
674 | logger.error(f"Upload by web: [{up_info.path}] network error(2)!")
675 | if up_info.callback:
676 | up_info.callback(up_info.path, 1, 1, 'error')
677 | return UpCode(code=Cloud189.NETWORK_ERROR, path=up_info.path)
678 | sessionKey = re.findall(r"sessionKey = '(.+?)'", resp.text)[0]
679 |
680 | def _call_back(read_monitor):
681 | if up_info.callback:
682 | if not self._upload_finished_flag:
683 | up_info.callback(up_info.path, read_monitor.len, read_monitor.bytes_read)
684 | if read_monitor.len == read_monitor.bytes_read:
685 | self._upload_finished_flag = True
686 |
687 | with open(up_info.path, 'rb') as file_:
688 | post_data = MultipartEncoder({
689 | "parentId": up_info.fid,
690 | "fname": up_info.name,
691 | "sessionKey": sessionKey,
692 | "albumId": "undefined",
693 | "opertype": "1",
694 | "upload_file": (up_info.name, file_, 'application/octet-stream')
695 | })
696 | headers = {"Content-Type": post_data.content_type}
697 | self._upload_finished_flag = False # 上传完成的标志
698 |
699 | monitor = MultipartEncoderMonitor(post_data, _call_back)
700 | result = self._post(upload_url, data=monitor, headers=headers, timeout=None)
701 | fid = ''
702 | if result:
703 | result = result.json()
704 | if 'id' in result:
705 | call_back_msg = ''
706 | fid = result['id']
707 | code = Cloud189.SUCCESS
708 | else:
709 | call_back_msg = 'error'
710 | code = Cloud189.FAILED
711 | logger.error(f"Upload by web: [{up_info.path}] failed, {result=}")
712 | else: # 网络异常
713 | call_back_msg = 'error'
714 | code = Cloud189.NETWORK_ERROR
715 | logger.error(f"Upload by web: [{up_info.path}] network error(3)!")
716 | if up_info.callback:
717 | up_info.callback(up_info.path, 1, 1, call_back_msg)
718 | return UpCode(code=code, id=fid, path=up_info.path)
719 |
720 |
721 | def _check_up_file_exist(self, up_info: UpInfo) -> UpInfo:
722 | """检查文件是否已经存在"""
723 | if not up_info.force:
724 | files_info, _ = self.get_file_list(up_info.fid)
725 | for file_info in files_info:
726 | if up_info.name == file_info.name and up_info.size == file_info.size:
727 | logger.debug(f"Check file exist: {up_info.path} already exist! {file_info.id}")
728 | up_info = up_info._replace(id=file_info.id, exist=True)
729 | break
730 | return up_info
731 |
732 | def upload_file(self, file_path, folder_id=-11, force=False, callback=None) -> UpCode:
733 | """单个文件上传接口
734 | :param str file_path: 待上传文件路径
735 | :param int folder_id: 上传目录 id
736 | :param bool force: 强制上传已经存在的文件(文件名、大小一致的文件)
737 | :param func callback: 上传进度回调
738 | :return: UpCode
739 | """
740 | if not os.path.isfile(file_path):
741 | logger.error(f"Upload file: [{file_path}] is not a file!")
742 | return UpCode(code=Cloud189.PATH_ERROR, path=file_path)
743 |
744 | file_name = os.path.basename(file_path)
745 | file_size = os.path.getsize(file_path) # Byte
746 | up_info = self._check_up_file_exist(UpInfo(name=file_name, path=file_path, size=file_size,
747 | fid=str(folder_id), force=force, callback=callback))
748 | if not force and up_info.exist:
749 | logger.debug(f"Abandon upload because the file is already exist: {file_path=}")
750 | if up_info.callback:
751 | up_info.callback(up_info.path, 1, 1, 'exist')
752 | return UpCode(code=Cloud189.SUCCESS, id=up_info.id, path=file_path)
753 | elif self._sessionKey and self._sessionSecret and self._accessToken:
754 | logger.debug(f"Use the client interface to upload files: {file_path=}, {folder_id=}")
755 | return self._upload_file_by_client(up_info)
756 | else:
757 | logger.debug(f"Use the web interface to upload files: {file_path=}, {folder_id=}")
758 | return self._upload_file_by_web(up_info)
759 |
760 | def upload_dir(self, folder_path, parrent_fid=-11, force=False, mkdir=True, callback=None,
761 | failed_callback=None, up_handler= None):
762 | """文件夹上传接口
763 | :param str file_path: 待上传文件路径
764 | :param int folder_id: 上传目录 id
765 | :param bool force: 强制上传已经存在的文件(文件名、大小一致的文件)
766 | :param bool mkdir: 是否在 parrent_fid 创建父文件夹
767 | :param func callback: 上传进度回调
768 | :param func failed_callback: 错误回调
769 | :param func up_handler: 上传文件数回调
770 | :return: UpCode list or Cloud189 error code(mkdir error)
771 | """
772 | if not os.path.isdir(folder_path):
773 | logger.error(f"Upload dir: [{folder_path}] is not a file")
774 | return UpCode(Cloud189.PATH_ERROR)
775 |
776 | dir_dict = {}
777 | logger.debug(f'Upload dir: start parsing {folder_path=} structure...')
778 | upload_files = []
779 | folder_name = get_file_name(folder_path)
780 | if mkdir:
781 | result = self.mkdir(parrent_fid, folder_name)
782 | if result.code != Cloud189.SUCCESS:
783 | return result # MkCode
784 |
785 | dir_dict[folder_name] = result.id
786 | else:
787 | dir_dict[folder_name] = parrent_fid
788 |
789 | for home, dirs, files in os.walk(folder_path):
790 | for _file in files:
791 | f_path = home + os.sep + _file
792 | f_rfolder = get_relative_folder(f_path, folder_path)
793 | logger.debug(f"Upload dir: {f_rfolder=}, {f_path=}, {folder_path=}")
794 | if f_rfolder not in dir_dict:
795 | dir_dict[f_rfolder] = ''
796 | upload_files.append((f_path, dir_dict[f_rfolder]))
797 | for _dir in dirs:
798 | p_rfolder = get_relative_folder(
799 | home, folder_path, is_file=False)
800 | logger.debug(f"Upload dir: {p_rfolder=}, {home=}, {folder_path=}")
801 | dir_rname = p_rfolder + os.sep + _dir # 文件夹相对路径
802 |
803 | result = self.mkdir(dir_dict[p_rfolder], _dir)
804 | if result.code != Cloud189.SUCCESS:
805 | logger.error(
806 | f"Upload dir: create a folder in the upload sub folder{dir_rname=} failed! {folder_name=}, {dir_dict[p_rfolder]=}")
807 | return result # MkCode
808 | logger.debug(
809 | f"Upload dir: folder successfully created {folder_name=}, {dir_dict[p_rfolder]=}, {dir_rname=}, {result.id}")
810 | dir_dict[dir_rname] = result.id
811 | up_codes = []
812 | total_files = len(upload_files)
813 | for index, upload_file in enumerate(upload_files, start=1):
814 | if up_handler:
815 | up_handler(index, total_files)
816 | logger.debug(f"Upload dir: file [{upload_file[0]}] enter upload process...")
817 | up_code = self.upload_file(upload_file[0], upload_file[1], force=force, callback=callback)
818 | if failed_callback and up_code.code != Cloud189.SUCCESS:
819 | failed_callback(up_code.code, up_code.path)
820 | logger.debug(f"Up Dir Code: {up_code.code=}, {up_code.path=}")
821 | up_codes.append(up_code)
822 | logger.debug(f"Dir: {index=}, {total_files=}")
823 | return up_codes
824 |
825 | def get_file_info_by_id(self, fid) -> (int, FileInfo):
826 | '''获取文件(夹) 详细信息'''
827 | url = self._host_url + "/v2/getFileInfo.action"
828 | resp = self._get(url, params={'fileId': fid})
829 | if resp:
830 | resp = resp.json()
831 | else:
832 | return Cloud189.NETWORK_ERROR, FileInfo()
833 | # createAccount # createTime
834 | # fileId # fileIdDigest
835 | # fileName # fileSize
836 | # fileType # isFolder
837 | # lastOpTime # parentId
838 | # subFileCount
839 | name = resp['fileName']
840 | id_ = int(resp['fileId'])
841 | pid = int(resp['parentId'])
842 | ctime = resp['createTime']
843 | optime = resp['lastOpTime']
844 | size = resp['fileSize'] if 'fileSize' in resp else ''
845 | ftype = resp['fileType']
846 | isFolder = resp['isFolder']
847 | account = resp['createAccount']
848 | durl = resp['downloadUrl'] if 'downloadUrl' in resp else ''
849 | count = resp['subFileCount'] if 'subFileCount' in resp else ''
850 | return Cloud189.SUCCESS, FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime,
851 | size=size, ftype=ftype, isFolder=isFolder, account=account,
852 | durl=durl, count=count)
853 |
854 | def _down_one_link(self, durl, save_path, callback=None) -> int:
855 | """下载器"""
856 | if not os.path.exists(save_path):
857 | os.makedirs(save_path)
858 | os.environ['LANG'] = 'enUS.UTF-8'
859 | resp = self._get(durl, stream=True, timeout=None)
860 | if not resp:
861 | logger.error("Download link: network error!")
862 | return Cloud189.FAILED
863 |
864 | content_d = resp.headers['content-disposition'].encode('latin-1').decode('utf-8')
865 | file_name = re.search(r'filename="(.+)"', content_d)
866 | file_name = file_name.group(1) if file_name else ''
867 | if not file_name:
868 | logger.error("Download link: cannot get file name!")
869 | return Cloud189.FAILED
870 |
871 | file_path = save_path + os.sep + file_name
872 | total_size = resp.headers.get('content-length')
873 | if total_size:
874 | total_size = int(total_size)
875 | else: # no content length in headers
876 | total_size = -1
877 |
878 | now_size = 0
879 | if os.path.exists(file_path) and total_size != -1:
880 | now_size = os.path.getsize(file_path) # 本地已经下载的文件大小
881 | if now_size >= total_size: # 已经下载完成
882 | if callback is not None:
883 | callback(file_name, total_size, now_size, 'exist')
884 | logger.debug(f"Download link: the file already exists in the local {file_name=} {durl=}")
885 | return Cloud189.SUCCESS
886 | else: # 断点续传
887 | headers = {**self._headers, 'Range': 'bytes=%d-' % now_size}
888 | resp = self._get(durl, stream=True, headers=headers, timeout=None)
889 | if not resp:
890 | return Cloud189.FAILED
891 |
892 | logger.debug(f'Download link: {file_path=}, {now_size=}, {total_size=}')
893 | chunk_size = get_chunk_size(total_size)
894 | with open(file_path, "ab") as f:
895 | for chunk in resp.iter_content(chunk_size):
896 | if chunk:
897 | f.write(chunk)
898 | f.flush()
899 | now_size += len(chunk)
900 | if callback:
901 | callback(file_name, total_size, now_size)
902 | if total_size == -1 and callback:
903 | callback(file_name, now_size, now_size)
904 | logger.debug(f"Download link: finished {total_size=}, {now_size=}")
905 | return Cloud189.SUCCESS
906 |
907 | def down_file_by_id(self, fid, save_path='./Download', callback=None) -> int:
908 | """通过 fid 下载单个文件"""
909 | code, infos = self.get_file_info_by_id(fid)
910 | if code != Cloud189.SUCCESS:
911 | logger.error(f"Down by id: 获取文件{fid=}详情失败!")
912 | return code
913 | durl = 'https:' + infos.durl
914 | return self._down_one_link(durl, save_path, callback)
915 |
916 | def down_dirzip_by_id(self, fid, save_path='./Download', callback=None) -> int:
917 | """打包下载文件夹"""
918 | url = self._host_url + '/downloadMultiFiles.action'
919 | params = {
920 | 'fileIdS': fid,
921 | 'downloadType': 1,
922 | 'recursive': 1
923 | }
924 | resp = self._get(url, params=params, allow_redirects=False)
925 | if resp.status_code == requests.codes['found']: # 302
926 | durl = resp.headers.get("Location")
927 | else:
928 | logger.debug(f"Down folder failed: {resp.status_code}")
929 | return Cloud189.FAILED
930 |
931 | return self._down_one_link(durl, save_path, callback)
932 |
933 | def delete_by_id(self, fid):
934 | '''删除文件(夹)'''
935 | code, infos = self.get_file_info_by_id(fid)
936 | if code != Cloud189.SUCCESS:
937 | logger.error(f"Delete by id: get file's {fid=} details failed!")
938 | return code
939 |
940 | return self._batch_task(infos, 'DELETE')
941 |
942 | def move_file(self, info, target_id):
943 | '''移动文件(夹)'''
944 | return self._batch_task(info, 'MOVE', str(target_id))
945 |
946 | def cpoy_file(self, tasks, fid):
947 | '''复制文件(夹)'''
948 | code, infos = self.get_file_info_by_id(fid)
949 | if code != Cloud189.SUCCESS:
950 | logger.error(f"Copy by id: get file's {fid=} details failed!")
951 | return code
952 |
953 | return self._batch_task(infos, 'COPY')
954 |
955 | def mkdir(self, parent_id, fname):
956 | '''新建文件夹, 如果存在该文件夹,会返回存在的文件夹 id'''
957 | url = self._host_url + '/v2/createFolder.action'
958 | result = self._get(
959 | url, params={'parentId': str(parent_id), 'fileName': fname})
960 | if not result:
961 | logger.error("Mkdir: network error!")
962 | return MkCode(Cloud189.NETWORK_ERROR)
963 | result = result.json()
964 | if 'fileId' in result:
965 | return MkCode(Cloud189.SUCCESS, result['fileId'])
966 | else:
967 | logger.error(f"Mkdir: unknown error {result=}")
968 | return MkCode(Cloud189.MKDIR_ERROR)
969 |
970 | def rename(self, fid, fname):
971 | ''''重命名文件(夹)'''
972 | url = self._host_url + '/v2/renameFile.action'
973 | resp = self._get(url, params={'fileId': str(fid), 'fileName': fname})
974 | if not resp:
975 | logger.error("Rename: network error!")
976 | return Cloud189.NETWORK_ERROR
977 | resp = resp.json()
978 | if 'success' in resp:
979 | return Cloud189.SUCCESS
980 | logger.error(f"Rename: unknown error {resp=}, {fid=}, {fname=}")
981 | return Cloud189.FAILED
982 |
983 | def get_folder_nodes(self, fid=None, max_deep=5) -> TreeList:
984 | '''获取子文件夹信息
985 | :param fid: 需要获取子文件夹的文件夹id,None 表示获取所有文件夹
986 | :param max_deep: 子文件夹最大递归深度
987 | :return: TreeList 类
988 | '''
989 | tree = TreeList()
990 | url = self._host_url + "/getObjectFolderNodes.action"
991 | post_data = {"orderBy": '1', 'order': 'ASC'}
992 | deep = 1
993 |
994 | def _get_sub_folder(fid, deep):
995 | if fid:
996 | post_data.update({"id": str(fid)})
997 | params = {'pageNum': 1, 'pageSize': 500} # 应该没有大于 500 个文件夹的吧?
998 | resp = self._post(url, params=params, data=post_data)
999 | if not resp:
1000 | return
1001 | for folder in resp.json():
1002 | name = folder['name']
1003 | id_ = int(folder['id'])
1004 | pid = int(folder['pId']) if 'pId' in folder else ''
1005 | isParent = folder['isParent'] # str
1006 | tree.append(FolderTree(name=name, id=id_, pid=pid,
1007 | isParent=isParent), repeat=False)
1008 | logger.debug(
1009 | f"Sub Folder: {name=}, {id_=}, {pid=}, {isParent=}")
1010 | if deep < max_deep:
1011 | _get_sub_folder(id_, deep + 1)
1012 |
1013 | _get_sub_folder(fid, deep)
1014 | logger.debug(f"Sub Folder Tree len: {len(tree)}")
1015 | return tree
1016 |
1017 | def list_shared_url(self, stype: int, page: int = 1) -> FileList:
1018 | """列出自己的分享文件、转存的文件链接
1019 | :param stype: 1 发出的分享,2 收到的分享
1020 | :param page: 页面,60 条记录一页
1021 | :return: FileList 类
1022 | """
1023 | get_url = self._host_url + "/v2/listShares.action"
1024 | data = []
1025 | while True:
1026 | params = {"shareType": stype, "pageNum": page, "pageSize": 60}
1027 | resp = self._get(get_url, params=params)
1028 | if not resp:
1029 | logger.error("List shared: network error!")
1030 | return None
1031 | resp = resp.json()
1032 | data_, done = self._get_more_page(resp)
1033 | data.extend(data_)
1034 | if done:
1035 | break
1036 | page += 1
1037 | sleep(0.5) # 大量请求可能会被限制
1038 | results = FileList()
1039 | for item in data:
1040 | name = item['fileName']
1041 | id_ = item['fileId']
1042 | ctime = item['shareTime']
1043 | size = item['fileSize']
1044 | ftype = item['fileType']
1045 | isFolder = item['isFolder']
1046 | pwd = item['accessCode']
1047 | copyC = item['accessCount']['copyCount']
1048 | downC = item['accessCount']['downloadCount']
1049 | prevC = item['accessCount']['previewCount']
1050 | url = item['accessURL']
1051 | durl = item['downloadUrl']
1052 | path = item['filePath']
1053 | need_pwd = item['needAccessCode']
1054 | s_type = item['shareType']
1055 | s_mode = item['shareMode']
1056 | r_stat = item['reviewStatus']
1057 |
1058 | results.append(ShareInfo(name=name, id=id_, ctime=ctime, size=size, ftype=ftype,
1059 | isFolder=isFolder, pwd=pwd, copyC=copyC, downC=downC, prevC=prevC,
1060 | url=url, durl=durl, path=path, need_pwd=need_pwd, s_type=s_type,
1061 | s_mode=s_mode, r_stat=r_stat))
1062 |
1063 | return results
1064 |
1065 | def get_share_folder_info(self, share_id, verify_code, pwd='undefined'):
1066 | """获取分享的文件夹信息"""
1067 | result = []
1068 | page = 1
1069 | info_url = self._host_url + '/v2/listShareDir.action'
1070 | while True:
1071 | params = {
1072 | 'shareId': share_id,
1073 | 'verifyCode': verify_code,
1074 | 'accessCode': pwd or 'undefined',
1075 | 'orderBy': 1,
1076 | 'order': 'ASC',
1077 | 'pageNum': page,
1078 | 'pageSize': 60
1079 | }
1080 | resp = requests.get(info_url, params=params, headers=self._headers, verify=False)
1081 | if not resp:
1082 | return None
1083 | resp = resp.json()
1084 | if 'errorVO' in resp:
1085 | print("是文件夹,并且需要密码或者密码错误!")
1086 | logger.debug("Access password is required!")
1087 | return None
1088 | for item in resp['data']:
1089 | durl = 'https:' + item['downloadUrl'] if 'downloadUrl' in item else ''
1090 | print('#', item['fileId'], '文件名', item['fileName'], item['fileSize'], durl)
1091 | if resp['recordCount'] <= resp['pageSize'] * resp['pageNum']:
1092 | break
1093 |
1094 | page += 1
1095 | print(f"共 {resp['recordCount']} 条记录")
1096 | return result
1097 |
1098 | def get_share_file_info(self, share_id, pwd=''):
1099 | """获取分享的文件信息"""
1100 | verify_url = self._host_url + "/shareFileVerifyPass.action"
1101 | params = {
1102 | 'fileVO.id': share_id,
1103 | 'accessCode': pwd
1104 | }
1105 | resp = requests.get(verify_url, params=params, verify=False)
1106 | if not resp:
1107 | return None
1108 | resp = resp.json()
1109 | if not resp:
1110 | print("是文件,并且需要密码或者密码错误!")
1111 | return None
1112 | f_id = resp['fileId']
1113 | f_name = resp['fileName']
1114 | f_size = resp['fileSize']
1115 | f_type = resp['fileType']
1116 | durl = resp['longDownloadUrl']
1117 |
1118 | print(f_id, f_name, f_size, f_type, durl)
1119 |
1120 | def get_file_info_by_url(self, share_url, pwd=''):
1121 | """通过分享链接获取信息"""
1122 |
1123 | first_page = requests.get(share_url, headers=self._headers, verify=False)
1124 | if not first_page:
1125 | logger.error("File info: network error!")
1126 | return None
1127 | first_page = first_page.text
1128 | # 抱歉,您访问的页面地址有误,或者该页面不存在
1129 | if '您访问的页面地址有误' in first_page:
1130 | logger.debug(f"The sharing link has been cancelled {share_url}")
1131 | return None
1132 | if 'window.fileName' in first_page: # 文件
1133 | share_id = re.search(r'class="shareId" value="(\w+?)"', first_page).group(1)
1134 | # 没有密码,则直接暴露 durl
1135 | durl = re.search(r'class="downloadUrl" value="(\w+?)"', first_page)
1136 | if durl:
1137 | durl = durl.group(1)
1138 | print('直链:', durl)
1139 | return None
1140 | is_file = True
1141 | else: # 文件夹
1142 | share_id = re.search(r"_shareId = '(\w+?)';", first_page).group(1)
1143 | verify_code = re.search(r"_verifyCode = '(\w+?)';", first_page).group(1)
1144 | is_file = False
1145 |
1146 | if is_file:
1147 | return self.get_share_file_info(share_id, pwd)
1148 | else:
1149 | return self.get_share_folder_info(share_id, verify_code, pwd)
1150 |
1151 | def user_sign(self):
1152 | """签到 + 抽奖"""
1153 | sign_url = API + '//mkt/userSign.action'
1154 | headers = {
1155 | 'SessionKey': self._sessionKey
1156 | }
1157 | resp = requests.get(sign_url, headers=headers, verify=False)
1158 | if not resp:
1159 | logger.error("Sign: network error!")
1160 | if resp.status_code != requests.codes.ok:
1161 | print(f"签到失败 {resp=}, {headers=}")
1162 | else:
1163 | msg = re.search(r'获得.+?空间', resp.text)
1164 | msg = msg.group() if msg else ""
1165 | print(f"签到成功!{msg}。每天签到可领取更多福利哟,记得常来!")
1166 |
1167 | url = 'https://m.cloud.189.cn/v2/drawPrizeMarketDetails.action'
1168 | params = {
1169 | 'taskId': 'TASK_SIGNIN',
1170 | 'activityId': 'ACT_SIGNIN'
1171 | }
1172 | for i in range(1, 3):
1173 | resp = self._get(url, params=params)
1174 | if not resp:
1175 | logger.error("Sign: network error!")
1176 | resp = resp.json()
1177 | if 'errorCode' in resp:
1178 | print(f"今日抽奖({i})次数已用完: {resp['errorCode']}")
1179 | else:
1180 | print(f"今日抽奖({i})次:{resp['prizeName']}")
1181 | params.update({'taskId': 'TASK_SIGNIN_PHOTOS'})
1182 |
1183 | def get_user_infos(self):
1184 | """获取登录用户信息"""
1185 | url = self._host_url + "/v2/getLoginedInfos.action"
1186 | resp = self._get(url)
1187 | if not resp:
1188 | logger.error("Get user info: network error!")
1189 | return None
1190 | resp = resp.json()
1191 | id_ = resp['userId']
1192 | account = resp['userAccount']
1193 | nickname = resp['nickname'] if 'nickname' in resp else ''
1194 | used = resp['usedSize']
1195 | quota = resp['quota']
1196 | vip = resp['superVip'] if 'superVip' in resp else ''
1197 | endTime = resp['superEndTime'] if 'superEndTime' in resp else ''
1198 | beginTime = resp['superBeginTime'] if 'superBeginTime' in resp else ''
1199 | domain = resp['domainName'] if 'domainName' in resp else ''
1200 | return UserInfo(id=id_, account=account, nickname=nickname, used=used, quota=quota,
1201 | vip=vip, endTime=endTime, beginTime=beginTime, domain=domain)
1202 |
--------------------------------------------------------------------------------
/cloud189/api/models.py:
--------------------------------------------------------------------------------
1 | """
2 | 容器类,用于储存文件、文件夹,支持 list 的操作,同时支持许多方法方便操作元素
3 | 元素类型为 namedtuple,至少拥有 name id 两个属性才能放入容器
4 | """
5 |
6 | __all__ = ['FileList', 'PathList', 'TreeList']
7 |
8 |
9 | class ItemList:
10 | """具有 name, id 属性对象的列表"""
11 |
12 | def __init__(self):
13 | self._items = []
14 |
15 | def __len__(self):
16 | return len(self._items)
17 |
18 | def __getitem__(self, index):
19 | return self._items[index]
20 |
21 | def __iter__(self):
22 | return iter(self._items)
23 |
24 | def __repr__(self):
25 | return f""
26 |
27 | def __lt__(self, other):
28 | """用于路径 List 之间排序"""
29 | return '/'.join(i.name for i in self) < '/'.join(i.name for i in other)
30 |
31 | @property
32 | def name_id(self):
33 | """所有 item 的 name-id 列表,兼容旧版"""
34 | return {it.name: it.id for it in self}
35 |
36 | @property
37 | def all_name(self):
38 | """所有 item 的 name 列表"""
39 | return [it.name for it in self]
40 |
41 | def append(self, item, repeat=True):
42 | """在末尾插入元素"""
43 | if (not repeat) and self.find_by_id(item.id):
44 | # logger.debug(f"List: 不插入元素 {item.name}")
45 | return None
46 | self._items.append(item)
47 | # logger.debug(f"List: 插入元素 {item.name}")
48 |
49 | def index(self, item):
50 | """获取索引"""
51 | return self._items.index(item)
52 |
53 | def insert(self, pos, item):
54 | """指定位置插入元素"""
55 | self._items.insert(pos, item)
56 |
57 | def clear(self):
58 | """清空元素"""
59 | self._items.clear()
60 |
61 | def filter(self, condition) -> list:
62 | """筛选出满足条件的 item
63 | condition(item) -> True
64 | """
65 | return [it for it in self if condition(it)]
66 |
67 | def find_by_name(self, name: str):
68 | """使用文件名搜索(仅返回首个匹配项)"""
69 | for item in self:
70 | if name == item.name:
71 | return item
72 | return None
73 |
74 | def find_by_id(self, fid: int):
75 | """使用 id 搜索(精确)"""
76 | for item in self:
77 | if fid == item.id:
78 | return item
79 | return None
80 |
81 | def pop_by_id(self, fid):
82 | for item in self:
83 | if item.id == fid:
84 | self._items.remove(item)
85 | return item
86 | return None
87 |
88 | def update_by_id(self, fid, **kwargs):
89 | """通过 id 搜索元素并更新"""
90 | item = self.find_by_id(fid)
91 | pos = self.index(item)
92 | data = item._asdict()
93 | data.update(kwargs)
94 | self._items[pos] = item.__class__(**data)
95 |
96 | def get_absolute_path(self, fid) -> str:
97 | res = ''
98 | if item := self.find_by_id(fid):
99 | if item.pid:
100 | res = self.get_absolute_path(item.pid) + '/' + item.name
101 | else:
102 | res = item.name + res
103 | return res
104 |
105 | def get_path_id(self) -> dict:
106 | """获取文件路径-id"""
107 | result = {}
108 | for item in self._items:
109 | _id = item.id
110 | full_path = self.get_absolute_path(_id)
111 | result[full_path] = _id
112 | return result
113 |
114 |
115 | class FileList(ItemList):
116 | """文件列表类"""
117 | pass
118 |
119 |
120 | class PathList(ItemList):
121 | """路径列表类"""
122 | pass
123 |
124 |
125 | class TreeList(ItemList):
126 | """文件夹结构类"""
127 | pass
128 |
--------------------------------------------------------------------------------
/cloud189/api/token.py:
--------------------------------------------------------------------------------
1 | """
2 | 模拟客户端登录,获取 token,用于秒传检查
3 | """
4 |
5 | import re
6 | import requests
7 |
8 | from cloud189.api.utils import rsa_encode, calculate_md5_sign, API, get_time, UA, logger
9 | from cloud189.api import Cloud189
10 |
11 |
12 | def get_token_pre_params():
13 | """登录前参数准备"""
14 | url = 'https://cloud.189.cn/unifyLoginForPC.action'
15 | params = {
16 | 'appId': 8025431004,
17 | 'clientType': 10020,
18 | 'returnURL': 'https://m.cloud.189.cn/zhuanti/2020/loginErrorPc/index.html',
19 | 'timeStamp': get_time(stamp=True)
20 | }
21 | resp = requests.get(url, params=params)
22 | if not resp:
23 | return Cloud189.NETWORK_ERROR, None
24 |
25 | param_id = re.search(r'paramId = "(\S+)"', resp.text, re.M)
26 | req_id = re.search(r'reqId = "(\S+)"', resp.text, re.M)
27 | return_url = re.search(r"returnUrl = '(\S+)'", resp.text, re.M)
28 | captcha_token = re.search(r"captchaToken' value='(\S+)'", resp.text, re.M)
29 | j_rsakey = re.search(r'j_rsaKey" value="(\S+)"', resp.text, re.M)
30 | lt = re.search(r'lt = "(\S+)"', resp.text, re.M)
31 |
32 | param_id = param_id.group(1) if param_id else ''
33 | req_id = req_id.group(1) if req_id else ''
34 | return_url = return_url.group(1) if return_url else ''
35 | captcha_token = captcha_token.group(1) if captcha_token else ''
36 | j_rsakey = j_rsakey.group(1) if j_rsakey else ''
37 | lt = lt.group(1) if lt else ''
38 |
39 | return Cloud189.SUCCESS, (param_id, req_id, return_url, captcha_token, j_rsakey, lt)
40 |
41 |
42 | def get_token(username, password):
43 | """获取token"""
44 | code, result = get_token_pre_params()
45 | if code != Cloud189.SUCCESS:
46 | return code, None
47 |
48 | param_id, req_id, return_url, captcha_token, j_rsakey, lt = result
49 |
50 | username = rsa_encode(j_rsakey, username)
51 | password = rsa_encode(j_rsakey, password)
52 | url = "https://open.e.189.cn/api/logbox/oauth2/loginSubmit.do"
53 | headers = {
54 | "User-Agent": UA,
55 | "Referer": "https://open.e.189.cn/api/logbox/oauth2/unifyAccountLogin.do",
56 | "Cookie": f"LT={lt}",
57 | "X-Requested-With": "XMLHttpRequest",
58 | "REQID": req_id,
59 | "lt": lt
60 | }
61 | data = {
62 | "appKey": "8025431004",
63 | "accountType": "02",
64 | "userName": f"{{RSA}}{username}",
65 | "password": f"{{RSA}}{password}",
66 | "validateCode": "",
67 | "captchaToken": captcha_token,
68 | "returnUrl": return_url,
69 | "mailSuffix": "@189.cn",
70 | "dynamicCheck": "FALSE",
71 | "clientType": 10020,
72 | "cb_SaveName": 1,
73 | "isOauth2": 'false',
74 | "state": "",
75 | "paramId": param_id
76 | }
77 | resp = requests.post(url, data=data, headers=headers, timeout=10)
78 | if not resp:
79 | return Cloud189.NETWORK_ERROR, None
80 | resp = resp.json()
81 | if 'toUrl' in resp:
82 | redirect_url = resp['toUrl']
83 | else:
84 | redirect_url = ''
85 | logger.debug(f"Token: {resp['msg']=}")
86 | url = API + '/getSessionForPC.action'
87 | headers = {
88 | "User-Agent": UA,
89 | "Accept": "application/json;charset=UTF-8"
90 | }
91 | params = {
92 | 'clientType': 'TELEMAC',
93 | 'version': '1.0.0',
94 | 'channelId': 'web_cloud.189.cn',
95 | 'redirectURL': redirect_url
96 | }
97 | resp = requests.get(url, params=params, headers=headers, timeout=10)
98 | if not resp:
99 | return Cloud189.NETWORK_ERROR, None
100 |
101 | sessionKey = resp.json()['sessionKey']
102 | sessionSecret = resp.json()['sessionSecret']
103 | accessToken = resp.json()['accessToken'] # 需要再验证一次?
104 |
105 | url = API + '/open/oauth2/getAccessTokenBySsKey.action'
106 | timestamp = get_time(stamp=True)
107 | params = f'AppKey=601102120&Timestamp={timestamp}&sessionKey={sessionKey}'
108 | headers = {
109 | "AppKey": '601102120',
110 | 'Signature': calculate_md5_sign(params),
111 | "Sign-Type": "1",
112 | "Accept": "application/json",
113 | 'Timestamp': timestamp,
114 | }
115 | resp = requests.get(url, params={'sessionKey': sessionKey}, headers=headers, timeout=10)
116 | if not resp:
117 | return Cloud189.NETWORK_ERROR
118 | accessToken = resp.json()['accessToken']
119 |
120 | return Cloud189.SUCCESS, (sessionKey, sessionSecret, accessToken)
121 |
--------------------------------------------------------------------------------
/cloud189/api/types.py:
--------------------------------------------------------------------------------
1 | """
2 | API 处理后返回的数据类型
3 | """
4 |
5 | from collections import namedtuple
6 |
7 |
8 | __all__ = ['FileInfo', 'RecInfo', 'PathInfo', 'UpCode', 'MkCode', 'UpInfo',
9 | 'ShareCode', 'FolderTree', 'ShareInfo', 'UserInfo']
10 |
11 |
12 | _base_info = ['name', 'id', 'pid', 'ctime', 'optime', 'size', 'ftype', 'isFolder', 'durl']
13 | _file_info = (*_base_info, 'isStarred', 'account', 'count')
14 | _rec_info = [*_base_info, 'isFamily', 'path', 'fid']
15 | _share_info = ['pwd', 'copyC', 'downC', 'prevC', 'url', 'path',
16 | 'need_pwd', 's_type', 's_mode', 'r_stat', *_base_info]
17 |
18 | # 主文件
19 | FileInfo = namedtuple('FileInfo', _file_info, defaults=('',) * len(_file_info))
20 | # 回收站文件
21 | RecInfo = namedtuple('RecInfo', _rec_info, defaults=('',) * len(_rec_info))
22 | # 文件路径
23 | PathInfo = namedtuple('PathInfo', ['name', 'id', 'isCoShare'])
24 |
25 | UpCode = namedtuple('UpCode', ['code', 'id', 'quick_up', 'path'], defaults=(0, '', False, ''))
26 | MkCode = namedtuple('MkCode', ['code', 'id'], defaults=(0, ''))
27 | ShareCode = namedtuple('ShareCode', ['code', 'url', 'pwd', 'et'], defaults=(0, '', '', ''))
28 |
29 | FolderTree = namedtuple('FolderTree', ['name', 'id', 'pid', 'isParent'], defaults=('',) * 4)
30 |
31 | ShareInfo = namedtuple('ShareInfo', _share_info, defaults=('',) * len(_share_info))
32 | UserInfo = namedtuple('UserInfo', ['id', 'account', 'nickname', 'used', 'quota', 'vip', 'endTime',
33 | 'beginTime', 'domain'], defaults=('',) * 9)
34 |
35 | UpInfo = namedtuple('UpInfo', ['name', 'path', 'id', 'fid', 'size', 'force', 'exist', 'check', 'callback'],
36 | defaults=('', '', '', '-11', 0, False, False, True, None))
37 |
--------------------------------------------------------------------------------
/cloud189/api/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | API 处理网页数据、数据切片时使用的工具
3 | """
4 |
5 | import os
6 | import logging
7 | import hmac
8 | import hashlib
9 | from datetime import datetime
10 | from base64 import b64encode
11 | import rsa
12 |
13 | __all__ = ['logger', 'encrypt', 'b64tohex', 'calculate_hmac_sign',
14 | 'API', 'UA', 'SUFFIX_PARAM', 'get_time', 'get_file_md5',
15 | 'get_file_name', 'get_relative_folder', 'get_upload_chunks',
16 | 'get_chunk_size']
17 |
18 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
19 | ROOT_DIR = os.path.dirname(os.path.dirname(ROOT_DIR))
20 |
21 | # 调试日志设置
22 | logger = logging.getLogger('cloud189')
23 | log_file = ROOT_DIR + os.sep + 'debug-cloud189.log'
24 | fmt_str = "%(asctime)s [%(filename)s:%(lineno)d] %(funcName)s %(levelname)s - %(message)s"
25 | logging.basicConfig(level=logging.DEBUG,
26 | filename=log_file,
27 | filemode="a",
28 | format=fmt_str,
29 | datefmt="%Y-%m-%d %H:%M:%S")
30 |
31 | logging.getLogger("requests").setLevel(logging.WARNING)
32 | logging.getLogger("urllib3").setLevel(logging.WARNING)
33 |
34 | API = 'https://api.cloud.189.cn'
35 | UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) ????/1.0.0 ' \
36 | 'Chrome/69.0.3497.128 Electron/4.2.12 Safari/537.36 '
37 | # UA = 'Mozilla/5.0'
38 | SUFFIX_PARAM = 'clientType=TELEMAC&version=1.0.0&channelId=web_cloud.189.cn'
39 |
40 | RSA_KEY = """-----BEGIN PUBLIC KEY-----
41 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDY7mpaUysvgQkbp0iIn2ezoUyh
42 | i1zPFn0HCXloLFWT7uoNkqtrphpQ/63LEcPz1VYzmDuDIf3iGxQKzeoHTiVMSmW6
43 | FlhDeqVOG094hFJvZeK4OzA6HVwzwnEW5vIZ7d+u61RV1bsFxmB68+8JXs3ycGcE
44 | 4anY+YzZJcyOcEGKVQIDAQAB
45 | -----END PUBLIC KEY-----
46 | """
47 | b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
48 | BI_RM = list("0123456789abcdefghijklmnopqrstuvwxyz")
49 |
50 |
51 | def encrypt(password: str) -> str:
52 | return b64encode(
53 | rsa.encrypt(
54 | (password).encode('utf-8'),
55 | rsa.PublicKey.load_pkcs1_openssl_pem(RSA_KEY.encode())
56 | )
57 | ).decode()
58 |
59 |
60 | def int2char(a):
61 | return BI_RM[a]
62 |
63 |
64 | def b64tohex(a):
65 | d = ""
66 | e = 0
67 | for i in range(len(a)):
68 | if list(a)[i] != "=":
69 | v = b64map.index(list(a)[i])
70 | if 0 == e:
71 | e = 1
72 | d += int2char(v >> 2)
73 | c = 3 & v
74 | elif 1 == e:
75 | e = 2
76 | d += int2char(c << 2 | v >> 4)
77 | c = 15 & v
78 | elif 2 == e:
79 | e = 3
80 | d += int2char(c)
81 | d += int2char(v >> 2)
82 | c = 3 & v
83 | else:
84 | e = 0
85 | d += int2char(c << 2 | v >> 4)
86 | d += int2char(15 & v)
87 | if e == 1:
88 | d += int2char(c << 2)
89 | return d
90 |
91 |
92 | def md5(s):
93 | hl = hashlib.md5()
94 | hl.update(s.encode(encoding='utf-8'))
95 | return hl.hexdigest()
96 |
97 |
98 | def calculate_md5_sign(params):
99 | return hashlib.md5('&'.join(sorted(params.split('&'))).encode('utf-8')).hexdigest()
100 |
101 |
102 | def rsa_encode(j_rsakey, string):
103 | rsa_key = f"-----BEGIN PUBLIC KEY-----\n{j_rsakey}\n-----END PUBLIC KEY-----"
104 | pubkey = rsa.PublicKey.load_pkcs1_openssl_pem(rsa_key.encode())
105 | result = b64tohex((b64encode(rsa.encrypt(f'{string}'.encode(), pubkey))).decode())
106 | return result
107 |
108 |
109 | def calculate_hmac_sign(secret_key, session_key, operate, url, date):
110 | request_uri = url.split("?")[0].replace(f"{API}", "")
111 | plain = f'SessionKey={session_key}&Operate={operate}&RequestURI={request_uri}&Date={date}'
112 | return hmac.new(secret_key.encode(), plain.encode(), hashlib.sha1).hexdigest().upper()
113 |
114 |
115 | def get_time(stamp=False):
116 | '''获取当前时间戳'''
117 | if stamp:
118 | return str(int(datetime.utcnow().timestamp() * 1000))
119 | else:
120 | return datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
121 |
122 |
123 | def get_file_md5(file_path, check=True):
124 | if check:
125 | _md5 = hashlib.md5()
126 | with open(file_path, 'rb') as f:
127 | while True:
128 | data = f.read(64 * 1024)
129 | if not data:
130 | break
131 | _md5.update(data)
132 | hash_md5 = _md5.hexdigest()
133 | return hash_md5.upper()
134 | else:
135 | return 'random_md5_value' # TODO: 这里需要返回一个值
136 |
137 |
138 | def get_file_name(file_path):
139 | '''文件路径获取文件名'''
140 | return file_path.strip('/').strip('\\').rsplit('\\', 1)[-1].rsplit('/', 1)[-1]
141 |
142 |
143 | def get_relative_folder(full_path, work_path, is_file=True):
144 | '''文件路径获取文件夹'''
145 | work_name = get_file_name(work_path)
146 | # 有可能 work_name 在父文件夹中有出现,
147 | # 因此 反转路径 以替换最后一个文件(夹)名,最后再倒回来 (〒︿〒)
148 | work_hone = work_path[::-1].strip('/').strip('\\').replace(work_name[::-1], '', 1)[::-1]
149 | relative_path = full_path.strip('/').strip('\\').replace(work_hone, '')
150 | file_name = relative_path.rsplit('\\', 1)[-1].rsplit('/', 1)[-1] if is_file else ''
151 | logger.debug(f"{work_name=},{work_hone=},{relative_path=},{file_name=}")
152 | return relative_path.replace(file_name, '').strip('/').strip('\\')
153 |
154 |
155 | def get_upload_chunks(file, chunk_size=8096):
156 | """文件上传 块生成器"""
157 | while True:
158 | data = file.read(chunk_size)
159 | if not data: break
160 | yield data
161 |
162 |
163 | def get_chunk_size(total_size: int) -> int:
164 | """根据文件大小返回 块大小"""
165 | if total_size >= 1 << 30: # 1 GB
166 | return 10 << 20 # 10 MB
167 | elif total_size >= 100 << 20: # 100 MB
168 | return 4 << 20 # 4 MB
169 | elif total_size == -1:
170 | return 100 << 10 # 100 KB
171 | else:
172 | return 1 << 20 # 1 MB
173 |
--------------------------------------------------------------------------------
/cloud189/cli/__init__.py:
--------------------------------------------------------------------------------
1 | from cloud189.cli.config import config
2 |
3 | version = '0.0.5'
4 |
5 | __all__ = ['cli', 'utils', 'version', 'config']
6 |
--------------------------------------------------------------------------------
/cloud189/cli/cli.py:
--------------------------------------------------------------------------------
1 | import os
2 | from time import sleep
3 | from getpass import getpass
4 | from random import choice
5 | from sys import exit as exit_cmd
6 | from webbrowser import open_new_tab
7 |
8 | from cloud189.api import Cloud189
9 | from cloud189.api.models import FileList, PathList
10 | from cloud189.api.token import get_token
11 | from cloud189.api.utils import logger
12 |
13 | from cloud189.cli import config
14 | from cloud189.cli.downloader import Downloader, Uploader
15 | from cloud189.cli.manager import global_task_mgr
16 | from cloud189.cli.recovery import Recovery
17 | from cloud189.cli.utils import *
18 |
19 |
20 | class Commander:
21 | """网盘命令行"""
22 |
23 | def __init__(self):
24 | self._prompt = '> '
25 | self._disk = Cloud189()
26 | self._task_mgr = global_task_mgr
27 | self._dir_list = ''
28 | self._file_list = FileList()
29 | self._path_list = PathList()
30 | self._parent_id = -11
31 | self._parent_name = ''
32 | self._work_name = ''
33 | self._work_id = -11
34 | self._last_work_id = -11
35 | self._reader_mode = False
36 | self._reader_mode = config.reader_mode
37 | self._default_dir_pwd = ''
38 | self._disk.set_captcha_handler(captcha_handler)
39 |
40 | @staticmethod
41 | def clear():
42 | clear_screen()
43 |
44 | @staticmethod
45 | def help():
46 | print_help()
47 |
48 | @staticmethod
49 | def update():
50 | check_update()
51 |
52 | def bye(self):
53 | if self._task_mgr.has_alive_task():
54 | info("有任务在后台运行, 退出请直接关闭窗口")
55 | else:
56 | config.work_id = self._work_id
57 | exit_cmd(0)
58 |
59 | def rmode(self):
60 | """适用于屏幕阅读器用户的显示方式"""
61 | # TODO
62 | choice = input("以适宜屏幕阅读器的方式显示(y): ")
63 | if choice and choice.lower() == 'y':
64 | config.reader_mode = True
65 | self._reader_mode = True
66 | info("已启用 Reader Mode")
67 | else:
68 | config.reader_mode = False
69 | self._reader_mode = False
70 | info("已关闭 Reader Mode")
71 |
72 | def cdrec(self):
73 | """进入回收站模式"""
74 | rec = Recovery(self._disk)
75 | rec.run()
76 | self.refresh()
77 |
78 | def refresh(self, dir_id=None, auto=False):
79 | """刷新当前文件夹和路径信息"""
80 | dir_id = self._work_id if dir_id is None else dir_id
81 |
82 | if dir_id == -11:
83 | self._file_list, self._path_list = self._disk.get_root_file_list()
84 | else:
85 | self._file_list, self._path_list = self._disk.get_file_list(dir_id)
86 | if not self._file_list and not self._path_list:
87 | if auto:
88 | error(f"文件夹 id={dir_id} 无效(被删除), 将切换到根目录!")
89 | return self.refresh(-11)
90 | else:
91 | error(f"文件夹 id 无效 {dir_id=}, {self._work_id=}")
92 | return None
93 | self._prompt = '/'.join(self._path_list.all_name) + ' > '
94 | self._last_work_id = self._work_id
95 | self._work_name = self._path_list[-1].name
96 | self._work_id = self._path_list[-1].id
97 | if dir_id != -11: # 如果存在上级路径
98 | self._parent_name = self._path_list[-2].name
99 | self._parent_id = self._path_list[-2].id
100 |
101 | def login(self, args):
102 | """登录网盘"""
103 | if args:
104 | if '--auto' in args:
105 | if config.cookie and self._disk.login_by_cookie(config) == Cloud189.SUCCESS:
106 | self.refresh(config.work_id, auto=True)
107 | return None
108 | username = input('输入用户名:')
109 | password = getpass('输入密码:')
110 | if not username or not password:
111 | error('没有用户名或密码 :(')
112 | return None
113 | code = self._disk.login(username, password)
114 | if code == Cloud189.NETWORK_ERROR:
115 | error("登录失败,网络连接异常")
116 | return None
117 | elif code == Cloud189.FAILED:
118 | error('登录失败,用户名或密码错误 :(')
119 | os._exit(0)
120 | # 登录成功保存用户 cookie
121 | config.username = username
122 | config.password = password
123 | config.cookie = self._disk.get_cookie()
124 | code, token = get_token(username, password)
125 | if code == Cloud189.SUCCESS:
126 | config.set_token(*token)
127 | self._disk.set_session(*token)
128 | self._work_id = -11
129 | self.refresh(-11)
130 |
131 | def clogin(self):
132 | """使用 cookie 登录"""
133 | if platform() == 'Linux' and not os.environ.get('DISPLAY'):
134 | info("请使用浏览器打开: https://cloud.189.cn 获取 cookie")
135 | else:
136 | open_new_tab('https://cloud.189.cn')
137 | info("请设置 Cookie 内容:")
138 | c_login_user = input("COOKIE_LOGIN_USER=")
139 | if not c_login_user:
140 | error("请输入正确的 Cookie 信息")
141 | return None
142 | cookie = {"COOKIE_LOGIN_USER": str(c_login_user)}
143 | if self._disk.login_by_cookie(cookie) == Cloud189.SUCCESS:
144 | user = self._disk.get_user_infos()
145 | if not user:
146 | error("发生未知错误!")
147 | return None
148 | user_infos = {
149 | 'name': user.account.replace('@189.cn', ''),
150 | 'pwd': '',
151 | 'cookie': cookie,
152 | 'key': '',
153 | 'secret': '',
154 | 'token': '',
155 | 'save_path': './downloads',
156 | 'work_id': -11
157 | }
158 | config.set_infos(user_infos)
159 | self._work_id = config.work_id
160 | self.refresh()
161 | else:
162 | error("登录失败, 请检查 Cookie 是否正确")
163 |
164 | def logout(self, args):
165 | """注销/删除用户"""
166 | if args: # 删除用户
167 | for name in args:
168 | result = config.del_user(name)
169 | if result:
170 | info(f"成功删除用户 {name}")
171 | else:
172 | error(f"删除用户 {name} 失败!")
173 | return None
174 | clear_screen()
175 | self._prompt = '> '
176 | # self._disk.logout() # TODO(rachpt@126.com): 还没有注销登录的方法
177 | self._file_list.clear()
178 | self._path_list = ''
179 | self._parent_id = -11
180 | self._work_id = -11
181 | self._last_work_id = -11
182 | self._parent_name = ''
183 | self._work_name = ''
184 | config.cookie = None
185 |
186 | def su(self, args):
187 | """列出、切换用户"""
188 | users = config.get_users_name()
189 | def list_user():
190 | for i, user in enumerate(users):
191 | user_info = config.get_user_info(user)
192 | methord = "用户名+密码 登录" if user_info[2] else "Cookie 登录"
193 | print(f"[{i}] 用户名: {user}, {methord}")
194 | if args:
195 | if args[0] == '-l':
196 | list_user()
197 | return None
198 | elif args[0] in users:
199 | select_user = args[0]
200 | else:
201 | error(f"用户名 {args[0]} 无效")
202 | return None
203 | else:
204 | list_user()
205 | select = input("请输入用户序号, [0、1 ... ]: ")
206 | if select.isnumeric():
207 | select = int(select)
208 | if select > len(users):
209 | error(f"序号 {select} 无效!")
210 | return None
211 | select_user = users[select]
212 | else:
213 | error(f"序号 {select} 无效!")
214 | return None
215 | config.work_id = self._work_id # 保存旧的工作目录
216 | result = config.change_user(select_user)
217 | if result and self._disk.login_by_cookie(config) == Cloud189.SUCCESS:
218 | info(f"成功切换至用户 {config.username}")
219 | self.refresh(config.work_id)
220 | else:
221 | error("切换用户失败!")
222 |
223 | def ls(self, args):
224 | """列出文件(夹)"""
225 | fid = old_fid = self._work_id
226 | flag_full = False
227 | flag_arg_l = False
228 | if args:
229 | if len(args) >= 2:
230 | if args[0] == '-l':
231 | flag_full = True
232 | fname = args[-1]
233 | elif args[-1] == '-l':
234 | flag_full = True
235 | fname = args[0]
236 | else:
237 | info("暂不支持查看多个文件!")
238 | fname = args[0]
239 | else:
240 | if args[0] == '-l':
241 | flag_full = True
242 | flag_arg_l = True
243 | else:
244 | fname = args[0]
245 | if not flag_arg_l:
246 | if file := self._file_list.find_by_name(fname):
247 | if file.isFolder:
248 | fid = file.id
249 | else:
250 | error(f"{fname} 非文件夹,显示当前目录文件")
251 | else:
252 | error(f"{fname} 不存在,显示当前目录文件")
253 | if fid != old_fid:
254 | self._file_list, _ = self._disk.get_file_list(fid)
255 | if not flag_full: # 只罗列文件名
256 | for file in self._file_list:
257 | if file.isFolder:
258 | print(f"\033[1;34m{handle_name(file.name)}\033[0m", end=' ')
259 | else:
260 | print(f"{handle_name(file.name)}", end=' ')
261 | print()
262 | else:
263 | if self._reader_mode: # 方便屏幕阅读器阅读
264 | for file in self._file_list:
265 | print(
266 | f"{handle_name(file.name)} 大小:{get_file_size_str(file.size)} 上传时间:{file.ctime} ID:{file.id}")
267 | else: # 普通用户显示方式
268 | for file in self._file_list:
269 | star = '✦' if file.isStarred else '✧' # 好像 没什么卵用
270 | file_name = f"\033[1;34m{handle_name(file.name)}\033[0m" if file.isFolder else handle_name(file.name)
271 | print("# {0:<17}{1:<4}{2:<20}{3:>8} {4}".format(
272 | file.id, star, file.ctime, get_file_size_str(file.size), file_name))
273 | if fid != old_fid:
274 | self._file_list, _ = self._disk.get_file_list(old_fid)
275 |
276 | def cd(self, args):
277 | """切换工作目录"""
278 | dir_name = args[0]
279 | if not dir_name:
280 | info('cd .. 返回上级路径, cd - 返回上次路径, cd / 返回根目录')
281 | elif dir_name in ["..", "../"]:
282 | self.refresh(self._parent_id)
283 | elif dir_name == '/':
284 | self.refresh(-11)
285 | elif dir_name == '-':
286 | self.refresh(self._last_work_id)
287 | elif dir_name == '.':
288 | pass
289 | elif folder := self._file_list.find_by_name(dir_name):
290 | self.refresh(folder.id)
291 | else:
292 | error(f'文件夹不存在: {dir_name}')
293 |
294 | def mkdir(self, args):
295 | """创建文件夹"""
296 | if not args:
297 | info('参数:新建文件夹名')
298 | refresh_flag = False
299 | for name in args:
300 | if self._file_list.find_by_name(name):
301 | error(f'文件夹已存在: {name}')
302 | continue
303 | r = self._disk.mkdir(self._work_id, name)
304 | if r.code == Cloud189.SUCCESS:
305 | print(f"{name} ID: ", r.id)
306 | refresh_flag = True
307 | else:
308 | error(f'创建文件夹 {name} 失败!')
309 | continue
310 | if refresh_flag:
311 | self.refresh()
312 |
313 | def rm(self, args):
314 | """删除文件(夹)"""
315 | if not args:
316 | info('参数:删除文件夹(夹)名')
317 | return None
318 | for name in args:
319 | if file := self._file_list.find_by_name(name):
320 | self._disk.delete_by_id(file.id)
321 | print(f"删除:{name} 成功!")
322 | else:
323 | error(f"无此文件:{name}")
324 | self.refresh()
325 |
326 | def rename(self, args):
327 | """重命名文件(夹)"""
328 | name = args[0].strip(' ')
329 | if not name:
330 | info('参数:原文件名 [新文件名]')
331 | elif file := self._file_list.find_by_name(name):
332 | new = args[1].strip(' ') if len(args) == 2 else input("请输入新文件名:")
333 | logger.debug(f"{new=}, {args=}")
334 | code = self._disk.rename(file.id, new)
335 | if code == Cloud189.SUCCESS:
336 | self.refresh()
337 | elif code == Cloud189.NETWORK_ERROR:
338 | error('网络错误,请重试!')
339 | else:
340 | error('失败,未知错误!')
341 | else:
342 | error(f'没有找到文件(夹): {name}')
343 |
344 | def mv(self, args):
345 | """移动文件或文件夹"""
346 | name = args[0]
347 | if not name:
348 | info('参数:文件(夹)名 [新文件夹名/id]')
349 | folder_name = ''
350 | target_id = None
351 | file_info = self._file_list.find_by_name(name)
352 | if not file_info:
353 | error(f"文件(夹)不存在: {name}")
354 | return None
355 | if len(args) > 1:
356 | if args[-1].isnumeric():
357 | target_id = args[-1]
358 | else:
359 | folder_name = args[-1]
360 | if not target_id:
361 | info("正在获取所有文件夹信息,请稍后...")
362 | tree_list = self._disk.get_folder_nodes()
363 | if not tree_list:
364 | error("获取文件夹信息出错,请重试.")
365 | return None
366 | if folder_name:
367 | if folder := tree_list.find_by_name(folder_name):
368 | target_id = folder.id
369 | else:
370 | error(f"文件夹 {folder_name} 不存在!")
371 | return None
372 | else:
373 | tree_dict = tree_list.get_path_id()
374 | choice_list = list(tree_dict.keys())
375 |
376 | def _condition(typed_str, choice_str):
377 | path_depth = len(choice_str.split('/'))
378 | # 没有输入时, 补全 Cloud189,深度 1
379 | if not typed_str and path_depth == 1:
380 | return True
381 | # Cloud189/ 深度为 2,补全同深度的文件夹 Cloud189/test 、Cloud189/txt
382 | # Cloud189/tx 应该补全 Cloud189/txt
383 | if path_depth == len(typed_str.split('/')) and choice_str.startswith(typed_str):
384 | return True
385 |
386 | set_completer(choice_list, condition=_condition)
387 | choice = input('请输入路径(TAB键补全) : ')
388 | if not choice or choice not in choice_list:
389 | error(f"目标路径不存在: {choice}")
390 | return None
391 | target_id = tree_dict.get(choice)
392 |
393 | if self._disk.move_file(file_info, target_id) == Cloud189.SUCCESS:
394 | self._file_list.pop_by_id(file_info.id)
395 | else:
396 | error(f"移动文件(夹)到 {choice} 失败")
397 |
398 | def down(self, args):
399 | """自动选择下载方式"""
400 | task_flag = False
401 | follow = False
402 | for arg in args:
403 | if arg == '-f':
404 | follow = True
405 | args.remove(arg)
406 | # TODO: 通过分享链接下载
407 | i = 0
408 | while i < len(args):
409 | item = args[i]
410 | if item.startswith("http"):
411 | pwd = ''
412 | if i < len(args) - 1 and (not args[i + 1].startswith("http")):
413 | pwd = args[i + 1]
414 | i += 1 # 额外加一
415 | self._disk.get_file_info_by_url(item, pwd)
416 | elif file := self._file_list.find_by_name(item):
417 | downloader = Downloader(self._disk)
418 | f_path = '/'.join(self._path_list.all_name) # 文件在网盘的父路径
419 | if file.isFolder: # 使用 web 接口打包下载文件夹
420 | downloader.set_fid(file.id, is_file=False, f_path=f_path, f_name=item)
421 | task_flag = True
422 | self._task_mgr.add_task(downloader) # 提交下载任务
423 | else: # 下载文件
424 | downloader.set_fid(file.id, is_file=True, f_path=f_path, f_name=item)
425 | task_flag = True
426 | self._task_mgr.add_task(downloader) # 提交下载任务
427 | else:
428 | error(f'文件(夹)不存在: {item}')
429 | i += 1
430 | if follow and task_flag:
431 | self.jobs(['-f', ])
432 | elif task_flag:
433 | print("开始下载, 输入 jobs 查看下载进度...")
434 |
435 | def jobs(self, args):
436 | """显示后台任务列表"""
437 | follow = False
438 | for arg in args:
439 | if arg == '-f':
440 | print()
441 | follow = True
442 | args.remove(arg)
443 | if not args:
444 | self._task_mgr.show_tasks(follow)
445 | for arg in args:
446 | if arg.isnumeric():
447 | self._task_mgr.show_detail(int(arg), follow)
448 | else:
449 | self._task_mgr.show_tasks(follow)
450 |
451 | def upload(self, args):
452 | """上传文件(夹)"""
453 | if not args:
454 | info('参数:文件路径')
455 | task_flag = False
456 | follow = False
457 | force = False
458 | mkdir = True
459 | for arg in args:
460 | follow, force, mkdir, match = parsing_up_params(arg, follow, force, mkdir)
461 | if match:
462 | args.remove(arg)
463 | for path in args:
464 | path = path.strip('\"\' ') # 去除直接拖文件到窗口产生的引号
465 | if not os.path.exists(path):
466 | error(f'该路径不存在哦: {path}')
467 | continue
468 | uploader = Uploader(self._disk)
469 | if os.path.isfile(path):
470 | uploader.set_upload_path(path, is_file=True, force=force)
471 | else:
472 | uploader.set_upload_path(path, is_file=False, force=force, mkdir=mkdir)
473 | uploader.set_target(self._work_id, self._work_name)
474 | self._task_mgr.add_task(uploader)
475 | task_flag = True
476 | if follow and task_flag:
477 | self.jobs(['-f', ])
478 | elif task_flag:
479 | print("开始上传, 输入 jobs 查看上传进度...")
480 |
481 | def share(self, args):
482 | """分享文件"""
483 | name = args[0]
484 | if not name:
485 | info('参数:需要分享的文件 [1/2/3] [1/2]')
486 | return None
487 | if file := self._file_list.find_by_name(name):
488 | et = args[1] if len(args) >= 2 else None
489 | ac = args[2] if len(args) >= 3 else None
490 | result = self._disk.share_file(file.id, et, ac)
491 | if result.code == Cloud189.SUCCESS:
492 | print("-" * 50)
493 | print(f"{'文件夹名' if file.isFolder else '文件名 '} : {name}")
494 | print(f"上传时间 : {file.ctime}")
495 | if not file.isFolder:
496 | print(f"文件大小 : {get_file_size_str(file.size)}")
497 | print(f"分享链接 : {result.url}")
498 | print(f"提取码 : {result.pwd or '无'}")
499 | if result.et == '1':
500 | time = '1天'
501 | elif result.et == '2':
502 | time = '7天'
503 | else:
504 | time = '永久'
505 | print(f"有效期 : {time}")
506 | print("-" * 50)
507 | else:
508 | error('获取文件(夹)信息出错!')
509 | else:
510 | error(f"文件(夹)不存在: {name}")
511 |
512 | def shared(self, args):
513 | """显示分享文件"""
514 | stype = 1 # 默认查看 发出的分享
515 | if args and args[0] == '2':
516 | stype = 2 # 收到的分享
517 | all_file = self._disk.list_shared_url(stype)
518 | if not all_file:
519 | info("失败或者没有数据!")
520 | return None
521 | for item in all_file:
522 | f_name = item.name if item.isFolder else f"\033[1;34m{item.name}\033[0m" # 给你点颜色..
523 | print("https:{0:<30} 提取码: {1:>4} [转存/下载/浏览: {2}/{3}/{4}] 文件名: {5}".format(
524 | item.url, item.pwd, item.copyC, item.downC, item.prevC, f_name))
525 |
526 | def sign(self, args):
527 | """签到 + 抽奖"""
528 | if '-a' in args or '--all' in args:
529 | old_user = self.who()
530 | for user in config.get_users_name():
531 | self.su([user, ])
532 | sleep(0.5)
533 | self._disk.user_sign()
534 | sleep(0.5)
535 | self.su([old_user, ])
536 | else:
537 | self._disk.user_sign()
538 |
539 | def who(self):
540 | """打印当前登录账户信息,没有错误则返回用户名"""
541 | user = self._disk.get_user_infos()
542 | if not user:
543 | error("发生未知错误!")
544 | return None
545 | quota = ", 总空间: {:.3f} GB".format(user.quota/1073741824) # GB
546 | used = ", 已使用: {:.3f} GB".format(user.used/1073741824) # GB
547 | nickname = f", 昵称: {user.nickname}"
548 | print(f"账号: {user.account}, UID: {user.id}{nickname}{quota}{used}")
549 | # 99 家庭云黄金会员, 199 家庭云铂金会员 (可能不是这个的值)
550 | if user.vip == 100:
551 | vip = "黄金会员"
552 | elif user.vip == 200:
553 | vip = "铂金会员"
554 | else: # 0
555 | vip = "普通会员"
556 | start_time = f", 开始时间: {user.beginTime}" if user.beginTime else ''
557 | end_time = f", 到期时间: {user.endTime}" if user.endTime else ''
558 | print(f"用户类别: {vip}{start_time}{end_time}")
559 | if user.domain:
560 | print(f"个人主页: https://cloud.189.cn/u/{user.domain}")
561 | return user.account.replace('@189.cn', '')
562 |
563 | def setpath(self):
564 | """设置下载路径"""
565 | print(f"当前下载路径 : {config.save_path}")
566 | path = input('修改为 -> ').strip("\"\' ")
567 | if os.path.isdir(path):
568 | config.save_path = path
569 | else:
570 | error('路径非法,取消修改')
571 |
572 | def ll(self, args):
573 | """列出文件(夹),详细模式"""
574 | if choice((0, 1, 0)): # 1/3 概率刷新
575 | self.refresh()
576 | self.ls(['-l', *args])
577 |
578 | def quota(self):
579 | self.who()
580 |
581 | def exit(self):
582 | self.bye()
583 |
584 | def b(self):
585 | self.bye()
586 |
587 | def r(self):
588 | self.refresh()
589 |
590 | def c(self):
591 | self.clear()
592 |
593 | def j(self, args):
594 | self.jobs(args)
595 |
596 | def u(self, args):
597 | self.upload(args)
598 |
599 | def d(self, args):
600 | self.down(args)
601 |
602 | def run_one(self, cmd, args):
603 | """运行单任务入口"""
604 | no_arg_cmd = ['help', 'update', 'who', 'quota']
605 | cmd_with_arg = ['ls', 'll', 'down', 'mkdir', 'su', 'sign', 'logout',
606 | 'mv', 'rename', 'rm', 'share', 'upload']
607 |
608 | if cmd in ("upload", "down"):
609 | if "-f" not in args:
610 | args.append("-f")
611 |
612 | if cmd in no_arg_cmd:
613 | getattr(self, cmd)()
614 | elif cmd in cmd_with_arg:
615 | getattr(self, cmd)(args)
616 | else:
617 | print(f"命令有误,或者不支持单任务运行 {cmd}")
618 |
619 | def run(self):
620 | """处理交互模式用户命令"""
621 | no_arg_cmd = ['bye', 'exit', 'cdrec', 'clear', 'clogin', 'help', 'r', 'c', 'b',
622 | 'refresh', 'rmode', 'setpath', 'update', 'who', 'quota']
623 | cmd_with_arg = ['ls', 'll', 'cd', 'down', 'jobs', 'shared', 'su', 'login', 'logout',
624 | 'mkdir', 'mv', 'rename', 'rm', 'share', 'upload', 'sign', 'j', 'u', 'd']
625 |
626 | choice_list = [handle_name(i) for i in self._file_list.all_name] # 引号包裹空格文件名
627 | cmd_list = no_arg_cmd + cmd_with_arg
628 | set_completer(choice_list, cmd_list=cmd_list)
629 |
630 | try:
631 | args = input(self._prompt).split(' ', 1)
632 | if len(args) == 0:
633 | return None
634 | except KeyboardInterrupt:
635 | print('')
636 | info('退出本程序请输入 bye 或 exit')
637 | return None
638 |
639 | cmd, args = (args[0], []) if len(args) == 1 else (
640 | args[0], handle_args(args[1])) # 命令, 参数(可带有空格, 没有参数就设为空)
641 |
642 | if cmd in no_arg_cmd:
643 | getattr(self, cmd)()
644 | elif cmd in cmd_with_arg:
645 | getattr(self, cmd)(args)
646 |
--------------------------------------------------------------------------------
/cloud189/cli/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pickle import load, dump
3 | from cloud189.api.utils import ROOT_DIR
4 |
5 |
6 | __all__ = ['config']
7 | KEY = 152
8 | config_file = ROOT_DIR + os.sep + '.config'
9 |
10 |
11 | def encrypt(key, s):
12 | b = bytearray(str(s).encode("utf-8"))
13 | n = len(b)
14 | c = bytearray(n * 2)
15 | j = 0
16 | for i in range(0, n):
17 | b1 = b[i]
18 | b2 = b1 ^ key
19 | c1 = b2 % 19
20 | c2 = b2 // 19
21 | c1 = c1 + 46
22 | c2 = c2 + 46
23 | c[j] = c1
24 | c[j + 1] = c2
25 | j = j + 2
26 | return c.decode("utf-8")
27 |
28 |
29 | def decrypt(ksa, s):
30 | c = bytearray(str(s).encode("utf-8"))
31 | n = len(c)
32 | if n % 2 != 0:
33 | return ""
34 | n = n // 2
35 | b = bytearray(n)
36 | j = 0
37 | for i in range(0, n):
38 | c1 = c[j]
39 | c2 = c[j + 1]
40 | j = j + 2
41 | c1 = c1 - 46
42 | c2 = c2 - 46
43 | b2 = c2 * 19 + c1
44 | b1 = b2 ^ ksa
45 | b[i] = b1
46 | return b.decode("utf-8")
47 |
48 |
49 | def save_config(cf):
50 | with open(config_file, 'wb') as f:
51 | dump(cf, f)
52 |
53 |
54 | class Config:
55 |
56 | def __init__(self):
57 | self._users = {}
58 | self._cookie = {}
59 | self._username = ""
60 | self._password = ""
61 | self._sessionKey = ""
62 | self._sessionSecret = ""
63 | self._accessToken = ""
64 | self._save_path = './downloads'
65 | self._work_id = -11
66 | self._reader_mode = False
67 |
68 | def update_user(self):
69 | if self._username:
70 | self._users[self._username] = (self._cookie, self._username, self._password,
71 | self._sessionKey, self._sessionSecret, self._accessToken,
72 | self._save_path, self._work_id)
73 |
74 | def del_user(self, name):
75 | name = self.encode(name)
76 | if name in self._users:
77 | del self._users[name]
78 | return True
79 | return False
80 |
81 | def change_user(self, name):
82 | name = self.encode(name)
83 | if name in self._users:
84 | user = self._users[name]
85 | self._cookie = user[0]
86 | self._username = user[1]
87 | self._password = user[2]
88 | self._sessionKey = user[3]
89 | self._sessionSecret = user[4]
90 | self._accessToken = user[5]
91 | self._save_path = user[6]
92 | self._work_id = user[7]
93 | save_config(self)
94 | return True
95 | return False
96 |
97 | def get_users_name(self):
98 | return [self.decode(user) for user in self._users]
99 |
100 | def get_user_info(self, name):
101 | name = self.encode(name)
102 | if name in self._users:
103 | return self._users[name]
104 |
105 | def encode(self, var):
106 | if isinstance(var, dict):
107 | for k, v in var.items():
108 | var[k] = encrypt(KEY, str(v))
109 | elif var:
110 | var = encrypt(KEY, str(var))
111 | return var
112 |
113 | def decode(self, var):
114 | try:
115 | if isinstance(var, dict):
116 | dvar = {} # 新开内存,否则会修改原字典
117 | for k, v in var.items():
118 | dvar[k] = decrypt(KEY, str(v))
119 | elif var:
120 | dvar = decrypt(KEY, var)
121 | else:
122 | dvar = None
123 | except Exception:
124 | dvar = None
125 | return dvar
126 |
127 | @property
128 | def cookie(self):
129 | return self.decode(self._cookie)
130 |
131 | @cookie.setter
132 | def cookie(self, value):
133 | self._cookie = self.encode(value)
134 | self.update_user()
135 | save_config(self)
136 |
137 | @property
138 | def username(self):
139 | return self.decode(self._username)
140 |
141 | @username.setter
142 | def username(self, value):
143 | self._username = self.encode(value)
144 | self.update_user()
145 | save_config(self)
146 |
147 | @property
148 | def password(self):
149 | return self.decode(self._password)
150 |
151 | @password.setter
152 | def password(self, value):
153 | self._password = self.encode(value)
154 | self.update_user()
155 | save_config(self)
156 |
157 | @property
158 | def key(self):
159 | return self.decode(self._sessionKey)
160 |
161 | @key.setter
162 | def key(self, value):
163 | self._sessionKey = self.encode(value)
164 | self.update_user()
165 | save_config(self)
166 |
167 | @property
168 | def secret(self):
169 | return self.decode(self._sessionSecret)
170 |
171 | @secret.setter
172 | def sectet(self, value):
173 | self._sessionSecret = self.encode(value)
174 | self.update_user()
175 | save_config(self)
176 |
177 | @property
178 | def token(self):
179 | return self.decode(self._accessToken)
180 |
181 | @token.setter
182 | def token(self, value):
183 | self._accessToken = self.encode(value)
184 | self.update_user()
185 | save_config(self)
186 |
187 | def set_token(self, key, secret, token):
188 | '''设置全部'''
189 | self._sessionKey = self.encode(key)
190 | self._sessionSecret = self.encode(secret)
191 | self._accessToken = self.encode(token)
192 | self.update_user()
193 | save_config(self)
194 |
195 | @property
196 | def save_path(self):
197 | return self._save_path
198 |
199 | @save_path.setter
200 | def save_path(self, value):
201 | self._save_path = value
202 | self.update_user()
203 | save_config(self)
204 |
205 | @property
206 | def reader_mode(self):
207 | return self._reader_mode
208 |
209 | @reader_mode.setter
210 | def reader_mode(self, value: bool):
211 | self._reader_mode = value
212 | self.update_user()
213 | save_config(self)
214 |
215 | @property
216 | def work_id(self):
217 | return self._work_id
218 |
219 | @work_id.setter
220 | def work_id(self, value):
221 | self._work_id = value
222 | self.update_user()
223 | save_config(self)
224 |
225 | def set_infos(self, infos: dict):
226 | if "name" in infos:
227 | self._username = self.encode(infos["name"])
228 | if "pwd" in infos:
229 | self._password = self.encode(infos["pwd"])
230 | if "cookie" in infos:
231 | self._cookie = self.encode(infos["cookie"])
232 | if "key" in infos:
233 | self._sessionKey = self.encode(infos["key"])
234 | if "secret" in infos:
235 | self._sessionSecret = self.encode(infos["secret"])
236 | if "token" in infos:
237 | self._accessToken = self.encode(infos["token"])
238 | if "save_path" in infos:
239 | self._save_path = infos["save_path"]
240 | if "work_id" in infos:
241 | self._work_id = infos["work_id"]
242 | save_config(self)
243 |
244 |
245 | # 全局配置对象
246 | try:
247 | with open(config_file, 'rb') as c:
248 | config = load(c)
249 | except:
250 | config = Config()
251 |
--------------------------------------------------------------------------------
/cloud189/cli/downloader.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from threading import Thread
3 | from os import sep as os_sep
4 |
5 | from cloud189.api import Cloud189
6 | from cloud189.cli import config
7 | from cloud189.cli.utils import why_error
8 |
9 |
10 | class TaskType(Enum):
11 | """后台任务类型"""
12 | UPLOAD = 0
13 | DOWNLOAD = 1
14 |
15 |
16 | class DownType(Enum):
17 | """下载类型枚举类"""
18 | INVALID_URL = 0
19 | FILE_URL = 1
20 | FOLDER_URL = 2
21 | FILE_ID = 3
22 | FOLDER_ID = 4
23 |
24 |
25 | class Downloader(Thread):
26 |
27 | def __init__(self, disk: Cloud189):
28 | super(Downloader, self).__init__()
29 | self._task_type = TaskType.DOWNLOAD
30 | self._save_path = config.save_path
31 | self._disk = disk
32 | self._pid = -1
33 | self._down_type = None
34 | self._down_args = None
35 | self._f_path = None
36 | self._f_name = ''
37 | self._now_size = 0
38 | self._total_size = 1
39 | self._msg = '' # 备用
40 | self._err_msg = []
41 |
42 | def _error_msg(self, msg):
43 | """显示错误信息, 后台模式时保存信息而不显示"""
44 | self._err_msg.append(msg)
45 |
46 | def set_task_id(self, pid):
47 | """设置任务 id"""
48 | self._pid = pid
49 |
50 | def get_task_id(self):
51 | """获取当前任务 id"""
52 | return self._pid
53 |
54 | def get_task_type(self):
55 | """获取当前任务类型"""
56 | return self._task_type
57 |
58 | def get_process(self) -> (int, int, str):
59 | """获取下载进度"""
60 | return self._now_size, self._total_size, ''
61 |
62 | def get_count(self) -> (int, int):
63 | """文件夹当前第几个文件(备用)"""
64 | return 1, 0
65 |
66 | def get_cmd_info(self):
67 | """获取命令行的信息"""
68 | return self._down_args, self._f_path + '/' + self._f_name
69 |
70 | def get_err_msg(self) -> list:
71 | """获取后台下载时保存的错误信息"""
72 | return self._err_msg
73 |
74 | def set_url(self, url):
75 | """设置 URL 下载任务"""
76 | pass
77 | '''
78 | if is_file_url(url): # 如果是文件
79 | self._down_args = url
80 | self._down_type = DownType.FILE_URL
81 | elif is_folder_url(url):
82 | self._down_args = url
83 | self._down_type = DownType.FOLDER_URL
84 | else:
85 | self._down_type = DownType.INVALID_URL
86 | '''
87 |
88 | def set_fid(self, fid, is_file=True, f_path=None, f_name=None):
89 | """设置 id 下载任务"""
90 | self._down_args = fid
91 | self._f_path = f_path # 文件(夹)名在网盘的父路径
92 | self._f_name = f_name # 文件(夹)名在网盘的名字
93 | self._down_type = DownType.FILE_ID if is_file else DownType.FOLDER_ID
94 |
95 | def _show_progress(self, file_name, total_size, now_size, msg=''):
96 | """更新下载进度的回调函数"""
97 | self._total_size = total_size
98 | self._now_size = now_size
99 | self._msg = msg
100 |
101 | def _show_down_failed(self, code, file):
102 | """文件下载失败时的回调函数"""
103 | if hasattr(file, 'url'):
104 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, URL: {file.url}")
105 | else:
106 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, ID: {file.id}")
107 |
108 | def run(self) -> None:
109 | if self._down_type == DownType.INVALID_URL:
110 | self._error_msg('(。>︿<) 该分享链接无效')
111 |
112 | elif self._down_type == DownType.FILE_URL:
113 | code = self._disk.down_file_by_url(self._down_args, '', self._save_path, self._show_progress)
114 | if code == Cloud189.LACK_PASSWORD:
115 | pwd = input('输入该文件的提取码 : ') or ''
116 | code2 = self._disk.down_file_by_url(self._down_args, str(pwd), self._save_path, self._show_progress)
117 | if code2 != Cloud189.SUCCESS:
118 | self._error_msg(f"文件下载失败: {why_error(code2)} -> {self._down_args}")
119 | elif code != Cloud189.SUCCESS:
120 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._down_args}")
121 |
122 | elif self._down_type == DownType.FOLDER_URL:
123 | code = self._disk.down_dir_by_url(self._down_args, '', self._save_path, callback=self._show_progress,
124 | mkdir=True, failed_callback=self._show_down_failed)
125 | if code == Cloud189.LACK_PASSWORD:
126 | pwd = input('输入该文件夹的提取码 : ') or ''
127 | code2 = self._disk.down_dir_by_url(self._down_args, str(pwd), self._save_path,
128 | callback=self._show_progress,
129 | mkdir=True, failed_callback=self._show_down_failed)
130 | if code2 != Cloud189.SUCCESS:
131 | self._error_msg(f"文件夹下载失败: {why_error(code2)} -> {self._down_args}")
132 | elif code != Cloud189.SUCCESS:
133 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._down_args}")
134 |
135 | elif self._down_type == DownType.FILE_ID:
136 | save_path = self._save_path + os_sep + self._f_path
137 | code = self._disk.down_file_by_id(self._down_args, save_path, self._show_progress)
138 | if code != Cloud189.SUCCESS:
139 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._f_path}")
140 |
141 | elif self._down_type == DownType.FOLDER_ID:
142 | save_path = self._save_path + os_sep + self._f_path + os_sep + self._f_name
143 | code = self._disk.down_dirzip_by_id(self._down_args, save_path, callback=self._show_progress)
144 | if code != Cloud189.SUCCESS:
145 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._f_path} ")
146 |
147 |
148 | class UploadType(Enum):
149 | """上传类型枚举类"""
150 | FILE = 0
151 | FOLDER = 1
152 |
153 |
154 | class Uploader(Thread):
155 |
156 | def __init__(self, disk: Cloud189):
157 | super(Uploader, self).__init__()
158 | self._task_type = TaskType.UPLOAD
159 | self._disk = disk
160 | self._pid = -1
161 | self._up_path = None
162 | self._force = False
163 | self._up_type = None
164 | self._folder_id = -11
165 | self._folder_name = ''
166 | self._msg = ''
167 | self._now_size = 0
168 | self._total_size = 1
169 | self._mkdir = True # for dir upload
170 | self._done_files = 0 # for dir upload
171 | self._total_files = 0 # for dir upload
172 | self._err_msg = []
173 |
174 | def _error_msg(self, msg):
175 | self._err_msg.append(msg)
176 |
177 | def set_task_id(self, pid):
178 | self._pid = pid
179 |
180 | def get_task_id(self):
181 | return self._pid
182 |
183 | def get_task_type(self):
184 | return self._task_type
185 |
186 | def get_process(self) -> (int, int, str):
187 | return self._now_size, self._total_size, self._msg
188 |
189 | def get_count(self) -> (int, int):
190 | """文件夹当前文件数量信息"""
191 | return self._done_files, self._total_files
192 |
193 | def get_cmd_info(self):
194 | return self._up_path, self._folder_name
195 |
196 | def get_err_msg(self) -> list:
197 | return self._err_msg
198 |
199 | def set_upload_path(self, path, is_file=True, force=False, mkdir=True):
200 | """设置上传路径信息"""
201 | self._up_path = path
202 | self._force = force
203 | self._mkdir = mkdir
204 | self._up_type = UploadType.FILE if is_file else UploadType.FOLDER
205 |
206 | def set_target(self, folder_id=-1, folder_name=''):
207 | """设置网盘保存文件夹信息"""
208 | self._folder_id = folder_id
209 | self._folder_name = folder_name
210 |
211 | def _show_progress(self, file_name, total_size, now_size, msg=''):
212 | self._total_size = total_size
213 | self._now_size = now_size
214 | self._msg = msg
215 |
216 | def _show_upload_failed(self, code, filename):
217 | """文件上传失败时的回调函数"""
218 | self._error_msg(f"上传失败: {why_error(code)} -> {filename}")
219 |
220 | def _set_dir_count(self, done_files, total_files):
221 | """文件夹中文件数量"""
222 | self._done_files = done_files
223 | self._total_files = total_files
224 |
225 | def run(self) -> None:
226 | if self._up_type == UploadType.FILE:
227 | info = self._disk.upload_file(self._up_path, self._folder_id, callback=self._show_progress, force=self._force)
228 | if info.code != Cloud189.SUCCESS:
229 | self._error_msg(f"上传失败: {why_error(info.code)} -> {self._up_path}")
230 |
231 | elif self._up_type == UploadType.FOLDER:
232 | infos = self._disk.upload_dir(self._up_path, self._folder_id, self._force, self._mkdir,
233 | callback=self._show_progress, failed_callback=self._show_upload_failed,
234 | up_handler=self._set_dir_count)
235 | if not isinstance(infos, list): # 进入单文件上传之前就已经出错(创建文件夹失败!) UpCode or MkCode
236 | self._error_msg(f"文件夹上传失败: {why_error(infos.code)} -> {self._up_path}")
237 |
--------------------------------------------------------------------------------
/cloud189/cli/manager.py:
--------------------------------------------------------------------------------
1 | from threading import Thread
2 | from time import time, sleep, monotonic
3 |
4 | from cloud189.cli.downloader import TaskType
5 | from cloud189.cli.utils import info, error, get_file_size_str, OS_NAME, get_upload_status
6 | from cloud189.cli.reprint import output # 修改了 magic_char
7 | from cloud189.api.utils import logger
8 |
9 | __all__ = ['global_task_mgr']
10 |
11 | OUTPUT_LIST = output()
12 | TOTAL_TASKS = 0
13 |
14 |
15 | class TimeoutExpired(Exception):
16 | pass
17 |
18 |
19 | def input_with_timeout(timeout=2):
20 | if OS_NAME == 'posix': # *nix
21 | import select
22 | import sys
23 |
24 | ready, _, _ = select.select([sys.stdin], [], [], timeout)
25 | if ready:
26 | try:
27 | return sys.stdin.readline().rstrip('\n')
28 | except OSError:
29 | return None
30 | raise TimeoutExpired
31 |
32 | else: # windows
33 | import msvcrt
34 |
35 | endtime = monotonic() + timeout
36 | result = []
37 | while monotonic() < endtime:
38 | if msvcrt.kbhit():
39 | result.append(msvcrt.getwche())
40 | if result[-1] == '\n' or result[-1] == '\r':
41 | return ''.join(result[:-1])
42 | sleep(0.05) # 这个值太大会导致丢失按键信息
43 | raise TimeoutExpired
44 |
45 |
46 | def sizeof_fmt(num, suffix='B'):
47 | for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
48 | if abs(num) < 1024.0:
49 | return "%6.1f%s%s" % (num, unit, suffix)
50 | num /= 1024.0
51 | return "%6.1f%s%s" % (num, 'Yi', suffix)
52 |
53 |
54 | class TaskManager(object):
55 | """下载/上传任务管理器"""
56 |
57 | def __init__(self):
58 | self._tasks = []
59 | self._last_updated = {}
60 |
61 | def is_empty(self):
62 | """任务列表是否为空"""
63 | return len(self._tasks) == 0
64 |
65 | def has_alive_task(self):
66 | """是否有任务在后台运行"""
67 | for task in self._tasks:
68 | if task.is_alive():
69 | return True
70 | return False
71 |
72 | def add_task(self, task):
73 | """提交一个上传/下载任务"""
74 | for t in self._tasks:
75 | if task.get_cmd_info() == t.get_cmd_info(): # 操作指令相同,认为是相同的任务
76 | old_pid = t.get_task_id()
77 | if t.is_alive(): # 下载任务正在运行
78 | info(f"任务正在后台运行: PID {old_pid}")
79 | return None
80 | else: # 下载任务为 Finished 或 Error 状态
81 | choice = input(f"任务已完成, PID {old_pid}, 重新下载吗?(y) ")
82 | if choice.lower() == 'y':
83 | task.set_task_id(old_pid)
84 | self._tasks[old_pid] = task
85 | task.start()
86 | return None
87 | # 没有发现重复的任务
88 | task.set_task_id(len(self._tasks))
89 | self._tasks.append(task)
90 | task.start()
91 |
92 | def _size_to_msg(self, now_size, total_size, msg, pid, task) -> str:
93 | now = time()
94 | if pid not in self._last_updated:
95 | self._last_updated[pid] = now
96 | speed = 0
97 | else:
98 | speed = now_size / (now - self._last_updated[pid])
99 |
100 | """任务详情可视化"""
101 | if total_size == -1: # zip 打包下载
102 | percent = get_file_size_str(now_size)
103 | else:
104 | percent = "{:8.1%}".format(now_size / total_size)
105 | has_error = len(task.get_err_msg()) != 0
106 | if task.is_alive(): # 任务执行中
107 | if now_size >= total_size: # 有可能 thread 关闭不及时
108 | status = '\033[1;34mFinished\033[0m'
109 | else:
110 | status = '\033[1;32mRunning \033[0m'
111 | elif not task.is_alive() and has_error: # 任务执行完成, 但是有错误信息
112 | status = '\033[1;31mError \033[0m'
113 | else: # 任务正常执行完成
114 | percent = "{:8.1%}".format(1) # 可能更新不及时
115 | status = '\033[1;34mFinished\033[0m'
116 | if task.get_task_type() == TaskType.DOWNLOAD:
117 | d_arg, f_name = task.get_cmd_info()
118 | d_arg = f_name if isinstance(d_arg, int) else d_arg # 显示 id 对应的文件名
119 | result = f"[{pid}] Status: {status} | Process: {percent} | Download: {d_arg}"
120 | else:
121 | up_path, folder_name = task.get_cmd_info()
122 | done_files, total_files = task.get_count()
123 | count = f" ({done_files}/{total_files})" if total_files > 0 else ""
124 | proc = get_upload_status(msg, percent)
125 | result = f"[{pid}] Status: {status} | Process:{proc} | Speed: {sizeof_fmt(speed)}/s | Upload: {up_path}{count} -> {folder_name}"
126 |
127 | return result
128 |
129 |
130 | def _show_task(self, pid, task, follow=False):
131 | TaskManager.running = True # 相当于每次执行 jobs -f 都初始化
132 | # TOTAL_TASKS 用于标记还没完成的任务数量
133 | global OUTPUT_LIST, TOTAL_TASKS
134 |
135 | def stop_show_task():
136 | """停止显示任务状态"""
137 | stop_signal = None
138 | while TaskManager.running or TOTAL_TASKS > 0:
139 | try:
140 | stop_signal = input_with_timeout(2)
141 | except (TimeoutExpired, OSError):
142 | sleep(0.5)
143 | else:
144 | if stop_signal:
145 | TaskManager.running = False
146 | logger.debug(f"Stop_show_task break by User! {stop_signal=}, {TOTAL_TASKS=}")
147 | break
148 | logger.debug(f"Stop_show_task Exit! {TaskManager.running=}, {TOTAL_TASKS=}")
149 |
150 | if follow: Thread(target=stop_show_task).start()
151 | now_size, total_size, msg = task.get_process()
152 | done_files, total_files = task.get_count()
153 | while total_size == -1 or now_size < total_size or done_files <= total_files:
154 | if not TaskManager.running:
155 | break # 用户中断
156 | if follow:
157 | now_size, total_size, msg = task.get_process()
158 | done_files, total_files = task.get_count()
159 | OUTPUT_LIST[pid] = self._size_to_msg(now_size, total_size, msg, pid, task)
160 | # 文件秒传、出错 没有大小,需要跳过秒传检查 msg
161 | if ((msg and msg != 'check') or now_size >= total_size) and done_files >= total_files:
162 | TOTAL_TASKS -= 1
163 | logger.debug(f"{pid=} While Loop Break! {msg=}, {TOTAL_TASKS=}, {done_files=}, {total_files=}")
164 | while True:
165 | if not task.is_alive():
166 | OUTPUT_LIST.append(f"[{pid}] finished")
167 | for err_msg in task.get_err_msg():
168 | OUTPUT_LIST.append(f"[{pid}] Error Messages: {err_msg}")
169 | break
170 | sleep(1)
171 | # 只有还有一个没有完成, 就不能改 TaskManager.running
172 | if TaskManager.running and TOTAL_TASKS < 1:
173 | TaskManager.running = False # 辅助控制 stop_show_task 线程的结束 🤣
174 | logger.debug(f"{pid=} TaskManager changed running value to False")
175 | break
176 | sleep(1)
177 | else:
178 | print(self._size_to_msg(now_size, total_size, msg, pid, task))
179 | break # 非实时显示模式,直接结束
180 |
181 | def _show_task_bar(self, pid=None, follow=False):
182 | """多行更新状态栏"""
183 | global OUTPUT_LIST, TOTAL_TASKS
184 | with output(output_type="list", initial_len=len(self._tasks), interval=0) as OUTPUT_LIST:
185 | pool = []
186 | TOTAL_TASKS = len(self._tasks)
187 | logger.debug(f"TaskManager: {TOTAL_TASKS=}")
188 | for _pid, task in enumerate(self._tasks):
189 | if pid is not None and _pid != pid: # 如果指定了 pid 就只更新 pid 这个 task
190 | continue
191 | t = Thread(target=self._show_task, args=(_pid, task, follow))
192 | t.start()
193 | pool.append(t)
194 | [t.join() for t in pool]
195 |
196 | def show_tasks(self, follow=False):
197 | """显示所有任务"""
198 | if self.is_empty():
199 | print(f"没有任务在后台运行哦")
200 | else:
201 | if not follow:
202 | print('-' * 100)
203 | if follow:
204 | self._show_task_bar(follow=follow)
205 | else:
206 | for pid, task in enumerate(self._tasks):
207 | self._show_task(pid, task)
208 | if not follow:
209 | print('-' * 100)
210 |
211 | def show_detail(self, pid=-1, follow=False):
212 | """显示指定任务详情"""
213 | if 0 <= pid < len(self._tasks):
214 | task = self._tasks[pid]
215 | self._show_task_bar(pid, follow)
216 | print("Error Messages:")
217 | for msg in task.get_err_msg():
218 | print(msg)
219 | else:
220 | error(f"进程号不存在: PID {pid}")
221 |
222 |
223 | # 全局任务管理器对象
224 | global_task_mgr = TaskManager()
--------------------------------------------------------------------------------
/cloud189/cli/recovery.py:
--------------------------------------------------------------------------------
1 | from cloud189.api import Cloud189
2 | from cloud189.cli.utils import *
3 | from cloud189.cli import config
4 |
5 |
6 | class Recovery:
7 | """回收站命令行模式"""
8 |
9 | def __init__(self, disk: Cloud189):
10 | self._prompt = 'Recovery > '
11 | self._reader_mode = config.reader_mode
12 | self._disk = disk
13 |
14 | print("回收站数据加载中...")
15 | self._file_list = disk.get_rec_file_list()
16 |
17 | def ls(self):
18 | if self._reader_mode: # 适宜屏幕阅读器的显示方式
19 | for file in self._file_list:
20 | print(f"{file.name} 大小:{get_file_size_str(file.size)} 删除时间:{file.optime} 路径:{file.path}")
21 | print("")
22 | else: # 普通用户的显示方式
23 | for file in self._file_list:
24 | print("#{0:<18}{1:<21} {3:>9} {4}\t{2}".format(file.id, file.optime, file.name, get_file_size_str(file.size), file.path))
25 | print('总文件数: ', len(self._file_list))
26 |
27 | def clean(self):
28 | """清空回收站"""
29 | if len(self._file_list) == 0:
30 | print("当前回收站为空!")
31 | else:
32 | choice = input('确认清空回收站?(y) ')
33 | if choice.lower() == 'y':
34 | if self._disk.rec_empty(self._file_list[0]) == Cloud189.SUCCESS:
35 | self._file_list.clear()
36 | info('回收站清空成功!')
37 | else:
38 | error('回收站清空失败!')
39 |
40 | def rm(self, name):
41 | """彻底删除文件(夹)"""
42 | if file := self._file_list.find_by_name(name): # 删除文件
43 | if self._disk.rec_delete(file) == Cloud189.SUCCESS:
44 | self._file_list.pop_by_id(file.id)
45 | else:
46 | error(f'彻底删除文件失败: {name}')
47 | else:
48 | error(f'文件不存在: {name}')
49 |
50 | def rec(self, name):
51 | """恢复文件"""
52 | if file := self._file_list.find_by_name(name):
53 | if self._disk.rec_restore(file) == Cloud189.SUCCESS:
54 | info(f"文件恢复成功: {name}")
55 | self._file_list.pop_by_id(file.id)
56 | else:
57 | error(f'彻底删除文件失败: {name}')
58 | else:
59 | error('(#`O′) 没有这个文件啊喂')
60 |
61 | def run(self):
62 | """在回收站模式下运行"""
63 | choice_list = self._file_list.all_name
64 | cmd_list = ['clean', 'cd', 'rec', 'rm']
65 | set_completer(choice_list, cmd_list=cmd_list)
66 |
67 | while True:
68 | try:
69 | args = input(self._prompt).split()
70 | if len(args) == 0:
71 | continue
72 | except KeyboardInterrupt:
73 | info('已退出回收站模式')
74 | break
75 |
76 | cmd, arg = args[0], ' '.join(args[1:])
77 |
78 | if cmd == 'ls':
79 | self.ls()
80 | elif cmd == 'clean':
81 | self.clean()
82 | elif cmd == 'rec':
83 | self.rec(arg)
84 | elif cmd == 'rm':
85 | self.rm(arg)
86 | elif cmd == 'cd' and arg == '..':
87 | print('')
88 | info('已退出回收站模式')
89 | break
90 | else:
91 | info('使用 cd .. 或 Crtl + C 退出回收站')
92 |
--------------------------------------------------------------------------------
/cloud189/cli/reprint.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, unicode_literals
3 |
4 | import re
5 | import sys
6 | import time
7 | import threading
8 | from math import ceil
9 |
10 | import six
11 | if six.PY2:
12 | from backports.shutil_get_terminal_size import get_terminal_size
13 | input = raw_input
14 | else:
15 | from shutil import get_terminal_size
16 | from builtins import input
17 |
18 | __all__ = ['output']
19 |
20 | last_output_lines = 0
21 | overflow_flag = False
22 | is_atty = sys.stdout.isatty()
23 |
24 | # magic_char = "\033[F"
25 | magic_char = "\x1b[1A"
26 |
27 | widths = [
28 | (126, 1), (159, 0), (687, 1), (710, 0), (711, 1),
29 | (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0),
30 | (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1),
31 | (8426, 0), (9000, 1), (9002, 2), (11021, 1), (12350, 2),
32 | (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1),
33 | (55203, 2), (63743, 1), (64106, 2), (65039, 1), (65059, 0),
34 | (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2),
35 | (120831, 1), (262141, 2), (1114109, 1),
36 | ]
37 |
38 | def get_char_width(char):
39 | global widths
40 | o = ord(char)
41 | if o == 0xe or o == 0xf:
42 | return 0
43 | for num, wid in widths:
44 | if o <= num:
45 | return wid
46 | return 1
47 |
48 | def width_cal_preprocess(content):
49 | """
50 | 此函数同时删除 ANSI escape code,避免影响行宽计算
51 | This function also remove ANSI escape code to avoid the influence on line width calculation
52 | """
53 | ptn = re.compile(r'(\033|\x1b)\[.*?m', re.I)
54 | _content = re.sub(ptn, '', content) # remove ANSI escape code
55 | return _content
56 |
57 | def preprocess(content):
58 | """
59 | 对输出内容进行预处理,转为str类型 (py3),并替换行内\r\t\n等字符为空格
60 | do pre-process to the content, turn it into str (for py3), and replace \r\t\n with space
61 | """
62 |
63 | if six.PY2:
64 | if not isinstance(content, unicode):
65 | if isinstance(content, str):
66 | _content = unicode(content, encoding=sys.stdin.encoding)
67 | elif isinstance(content, int):
68 | _content = unicode(content)
69 | else:
70 | _content = content
71 | assert isinstance(_content, unicode)
72 |
73 | elif six.PY3:
74 | _content = str(content)
75 |
76 | _content = re.sub(r'\r|\t|\n', ' ', _content)
77 | return _content
78 |
79 |
80 | def cut_off_at(content, width):
81 | if line_width(content) > width:
82 | now = content[:width]
83 | while line_width(now) > width:
84 | now = now[:-1]
85 | now += "$" * (width - line_width(now))
86 | return now
87 | else:
88 | return content
89 |
90 | def print_line(content, columns, force_single_line):
91 |
92 | padding = " " * ((columns - line_width(content)) % columns)
93 | output = "{content}{padding}".format(content=content, padding=padding)
94 | if force_single_line:
95 | output = cut_off_at(output, columns)
96 | print(output, end='')
97 | sys.stdout.flush()
98 |
99 |
100 | def line_width(line):
101 | """
102 | 计算本行在输出到命令行后所占的宽度
103 | calculate the width of output in terminal
104 | """
105 | if six.PY2:
106 | assert isinstance(line, unicode)
107 | _line = width_cal_preprocess(line)
108 | result = sum(map(get_char_width, _line))
109 | return result
110 |
111 |
112 | def lines_of_content(content, width):
113 | """
114 | 计算内容在特定输出宽度下实际显示的行数
115 | calculate the actual rows with specific terminal width
116 | """
117 | result = 0
118 | if isinstance(content, list):
119 | for line in content:
120 | _line = preprocess(line)
121 | result += ceil(line_width(_line) / width)
122 | elif isinstance(content, dict):
123 | for k, v in content.items():
124 | # 加2是算上行内冒号和空格的宽度
125 | # adding 2 for the for the colon and space ": "
126 | _k, _v = map(preprocess, (k, v))
127 | result += ceil((line_width(_k) + line_width(_v) + 2) / width)
128 | return int(result)
129 |
130 |
131 | def print_multi_line(content, force_single_line, sort_key):
132 | """
133 | 'sort_key' 参数只在 dict 模式时有效
134 | 'sort_key' parameter only available in 'dict' mode
135 | """
136 |
137 | global last_output_lines
138 | global overflow_flag
139 | global is_atty
140 |
141 | if not is_atty:
142 | if isinstance(content, list):
143 | for line in content:
144 | print(line)
145 | elif isinstance(content, dict):
146 | for k, v in sorted(content.items(), key=sort_key):
147 | print("{}: {}".format(k, v))
148 | else:
149 | raise TypeError("Excepting types: list, dict. Got: {}".format(type(content)))
150 | return
151 |
152 | columns, rows = get_terminal_size()
153 | lines = lines_of_content(content, columns)
154 | if force_single_line is False and lines > rows:
155 | overflow_flag = True
156 | elif force_single_line is True and len(content) > rows:
157 | overflow_flag = True
158 |
159 | # 确保初始输出位置是位于最左处的
160 | # to make sure the cursor is at the left most
161 | print("\b" * columns, end="")
162 |
163 | if isinstance(content, list):
164 | for line in content:
165 | _line = preprocess(line)
166 | print_line(_line, columns, force_single_line)
167 | elif isinstance(content, dict):
168 | for k, v in sorted(content.items(), key=sort_key):
169 | _k, _v = map(preprocess, (k, v))
170 | print_line("{}: {}".format(_k, _v), columns, force_single_line)
171 | else:
172 | raise TypeError("Excepting types: list, dict. Got: {}".format(type(content)))
173 |
174 | # 输出额外的空行来清除上一次输出的剩余内容
175 | # do extra blank lines to wipe the remaining of last output
176 | print(" " * columns * (last_output_lines - lines), end="")
177 |
178 | # 回到初始输出位置
179 | # back to the origin pos
180 | print(magic_char * (max(last_output_lines, lines)-1), end="")
181 | sys.stdout.flush()
182 | last_output_lines = lines
183 |
184 |
185 | class output:
186 |
187 | class SignalList(list):
188 |
189 | def __init__(self, parent, obj):
190 | super(output.SignalList, self).__init__(obj)
191 | self.parent = parent
192 | self.lock = threading.Lock()
193 |
194 | def __setitem__(self, key, value):
195 | global is_atty
196 | with self.lock:
197 | super(output.SignalList, self).__setitem__(key, value)
198 | if not is_atty:
199 | print("{}".format(value))
200 | else:
201 | self.parent.refresh(int(time.time()*1000), forced=False)
202 |
203 | def clear(self):
204 | global is_atty
205 | # with self.lock: In all places you call clear, you actually already have the lock
206 | if six.PY2:
207 | self[:] = []
208 | elif six.PY3:
209 | super(output.SignalList, self).clear()
210 |
211 | if is_atty:
212 | self.parent.refresh(int(time.time()*1000), forced=False)
213 |
214 | def change(self, newlist):
215 | with self.lock:
216 | self.clear()
217 | self.extend(newlist)
218 | if is_atty:
219 | self.parent.refresh(int(time.time()*1000), forced=False)
220 |
221 | def append(self, x):
222 | global is_atty
223 | with self.lock:
224 | super(output.SignalList, self).append(x)
225 | if not is_atty:
226 | print("{}".format(x))
227 | else:
228 | self.parent.refresh(int(time.time()*1000), forced=False)
229 |
230 | def insert(self, i, x):
231 | global is_atty
232 | with self.lock:
233 | super(output.SignalList, self).insert(i, x)
234 | if not is_atty:
235 | print("{}".format(x))
236 | else:
237 | self.parent.refresh(int(time.time()*1000), forced=False)
238 |
239 | def remove(self, x):
240 | global is_atty
241 | with self.lock:
242 | super(output.SignalList, self).remove(x)
243 | if is_atty:
244 | self.parent.refresh(int(time.time()*1000), forced=False)
245 |
246 | def pop(self, i=-1):
247 | global is_atty
248 | with self.lock:
249 | rs = super(output.SignalList, self).pop(i)
250 | if is_atty:
251 | self.parent.refresh(int(time.time()*1000), forced=False)
252 | return rs
253 |
254 | def sort(self, *args, **kwargs):
255 | global is_atty
256 | with self.lock:
257 | super(output.SignalList, self).sort(*args, **kwargs)
258 | if is_atty:
259 | self.parent.refresh(int(time.time()*1000), forced=False)
260 |
261 |
262 | class SignalDict(dict):
263 |
264 | def __init__(self, parent, obj):
265 | super(output.SignalDict, self).__init__(obj)
266 | self.parent = parent
267 | self.lock = threading.Lock()
268 |
269 | def change(self, newlist):
270 | with self.lock:
271 | self.clear()
272 | super(output.SignalDict, self).update(newlist)
273 | self.parent.refresh(int(time.time()*1000), forced=False)
274 |
275 | def __setitem__(self, key, value):
276 | global is_atty
277 | with self.lock:
278 | super(output.SignalDict, self).__setitem__(key, value)
279 | if not is_atty:
280 | print("{}: {}".format(key, value))
281 | else:
282 | self.parent.refresh(int(time.time()*1000), forced=False)
283 |
284 | def clear(self):
285 | global is_atty
286 | # with self.lock: In all places you call clear, you actually already have the lock
287 | super(output.SignalDict, self).clear()
288 | if is_atty:
289 | self.parent.refresh(int(time.time()*1000), forced=False)
290 |
291 | def pop(self, *args, **kwargs):
292 | global is_atty
293 | with self.lock:
294 | rs = super(output.SignalDict, self).pop(*args, **kwargs)
295 | if is_atty:
296 | self.parent.refresh(int(time.time()*1000), forced=False)
297 | return rs
298 |
299 | def popitem(self, *args, **kwargs):
300 | global is_atty
301 | with self.lock:
302 | rs = super(output.SignalDict, self).popitem(*args, **kwargs)
303 | if is_atty:
304 | self.parent.refresh(int(time.time()*1000), forced=False)
305 | return rs
306 |
307 | def setdefault(self, *args, **kwargs):
308 | global is_atty
309 | with self.lock:
310 | rs = super(output.SignalDict, self).setdefault(*args, **kwargs)
311 | if is_atty:
312 | self.parent.refresh(int(time.time()*1000), forced=False)
313 | return rs
314 |
315 | def update(self, *args, **kwargs):
316 | global is_atty
317 | with self.lock:
318 | super(output.SignalDict, self).update(*args, **kwargs)
319 | if is_atty:
320 | self.parent.refresh(int(time.time()*1000), forced=False)
321 |
322 |
323 | def __init__(self, output_type="list", initial_len=1, interval=0, force_single_line=False, no_warning=False, sort_key=lambda x:x[0]):
324 | self.sort_key = sort_key
325 | self.no_warning = no_warning
326 | no_warning and print("All reprint warning diabled.")
327 |
328 | global is_atty
329 | # reprint does not work in the IDLE terminal, and any other environment that can't get terminal_size
330 | if is_atty and not all(get_terminal_size()):
331 | if not no_warning:
332 | r = input("Fail to get terminal size, we got {}, continue anyway? (y/N)".format(get_terminal_size()))
333 | if not (r and isinstance(r, str) and r.lower()[0] in ['y','t','1']):
334 | sys.exit(0)
335 |
336 | is_atty = False
337 |
338 | if output_type == "list":
339 | self.warped_obj = output.SignalList(self, [''] * initial_len)
340 | elif output_type == "dict":
341 | self.warped_obj = output.SignalDict(self, {})
342 |
343 | self.interval = interval
344 | self.force_single_line = force_single_line
345 | self._last_update = int(time.time()*1000)
346 |
347 | def refresh(self, new_time=0, forced=True):
348 | if new_time - self._last_update >= self.interval or forced:
349 | print_multi_line(self.warped_obj, self.force_single_line, sort_key=self.sort_key)
350 | self._last_update = new_time
351 |
352 | def __enter__(self):
353 | global is_atty
354 | if not is_atty:
355 | if not self.no_warning:
356 | print("Not in terminal, reprint now using normal build-in print function.")
357 |
358 | return self.warped_obj
359 |
360 | def __exit__(self, exc_type, exc_val, exc_tb):
361 | global is_atty
362 |
363 | self.refresh(forced=True)
364 | if is_atty:
365 | columns, _ = get_terminal_size()
366 | if self.force_single_line:
367 | print('\n' * len(self.warped_obj), end="")
368 | else:
369 | print('\n' * lines_of_content(self.warped_obj, columns), end="")
370 | global last_output_lines
371 | global overflow_flag
372 | last_output_lines = 0
373 | if overflow_flag:
374 | if not self.no_warning:
375 | print("Detected that the lines of output has been exceeded the height of terminal windows, which \
376 | caused the former output remained and keep adding new lines.")
377 | print("检测到输出过程中, 输出行数曾大于命令行窗口行数, 这会导致输出清除不完整, 而使输出不停增长。请注意控制输出行数。")
378 |
--------------------------------------------------------------------------------
/cloud189/cli/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import logging
4 | from platform import system as platform
5 |
6 | import readline
7 | import requests
8 |
9 | from cloud189.api.utils import ROOT_DIR
10 | from cloud189.api import Cloud189
11 | from cloud189.cli import version
12 |
13 | __all__ = ['error', 'info', 'clear_screen', 'get_file_size_str', 'parsing_up_params',
14 | 'check_update', 'handle_name', 'handle_args', 'captcha_handler',
15 | 'set_completer', 'print_help', 'check_update']
16 |
17 | GIT_REPO = "Aruelius/cloud189"
18 | logging.getLogger("requests").setLevel(logging.WARNING)
19 | logging.getLogger("urllib3").setLevel(logging.WARNING)
20 | M_platform = platform()
21 | OS_NAME = os.name
22 | _ERR = False # 用于标识是否遇到错误
23 |
24 |
25 | def error(msg):
26 | global _ERR
27 | print(f"\033[1;31mError : {msg}\033[0m")
28 | _ERR = True
29 |
30 |
31 | def info(msg):
32 | print(f"\033[1;34mInfo : {msg}\033[0m")
33 |
34 |
35 | def clear_screen():
36 | """清空屏幕"""
37 | if os.name == 'nt':
38 | os.system('cls')
39 | else:
40 | os.system('clear')
41 |
42 |
43 | def get_file_size_str(filesize) -> str:
44 | if not filesize:
45 | return ''
46 | filesize = int(filesize)
47 | if 0 < filesize < 1 << 20:
48 | return f"{round(filesize/1024, 2)}KB"
49 | elif 1 << 20 < filesize < 1 << 30:
50 | return f"{round(filesize/(1 << 20), 2)}MB"
51 | elif 1 << 30 < filesize < 1 << 40:
52 | return f"{round(filesize/(1 << 30), 2)}GB"
53 | elif 1 << 40 < filesize < 1 << 50:
54 | return f"{round(filesize/(1 << 40), 2)}TB"
55 | else: return f"{filesize}Bytes"
56 |
57 |
58 | def why_error(code):
59 | """错误原因"""
60 | if code == Cloud189.URL_INVALID:
61 | return '分享链接无效'
62 | elif code == Cloud189.LACK_PASSWORD:
63 | return '缺少提取码'
64 | elif code == Cloud189.PASSWORD_ERROR:
65 | return '提取码错误'
66 | elif code == Cloud189.FILE_CANCELLED:
67 | return '分享链接已失效'
68 | elif code == Cloud189.NETWORK_ERROR:
69 | return '网络连接异常'
70 | elif code == Cloud189.CAPTCHA_ERROR:
71 | return '验证码错误'
72 | elif code == Cloud189.UP_COMMIT_ERROR:
73 | return '上传文件 commit 错误'
74 | elif code == Cloud189.UP_CREATE_ERROR:
75 | return '创建上传任务出错'
76 | elif code == Cloud189.UP_EXHAUSTED_ERROR:
77 | return '今日上传量已用完'
78 | elif code == Cloud189.UP_ILLEGAL_ERROR:
79 | return '文件非法'
80 | else:
81 | return '未知错误'
82 |
83 |
84 | def get_upload_status(msg, percent):
85 | """文件上传状态"""
86 | if msg == 'quick_up':
87 | return " \033[1;34m秒传!\033[0m "
88 | elif msg == 'check':
89 | return "\033[1;34m秒传检查\033[0m"
90 | elif msg == 'error':
91 | return "\033[1;31m秒传失败\033[0m"
92 | elif msg == 'exist':
93 | return "\033[1;31m远端存在\033[0m"
94 | elif msg == 'illegal':
95 | return "\033[1;31m非法文件\033[0m"
96 | elif msg == 'exhausted':
97 | return "\033[1;31m流量耗尽\033[0m"
98 | else:
99 | return percent
100 |
101 |
102 | def set_console_style():
103 | """设置命令行窗口样式"""
104 | if os.name != 'nt':
105 | return None
106 | os.system('mode 120, 40')
107 | os.system(f'title 天翼云盘-cli {version}')
108 |
109 |
110 | def captcha_handler(img_data):
111 | """处理下载时出现的验证码"""
112 | img_path = ROOT_DIR + os.sep + 'captcha.png'
113 | with open(img_path, 'wb') as f:
114 | f.write(img_data)
115 | if M_platform == 'Darwin':
116 | os.system(f'open {img_path}')
117 | elif M_platform == 'Linux':
118 | # 检测是否运行在没有显示屏的 console 上
119 | if os.environ.get('DISPLAY'):
120 | os.system(f'xdg-open {img_path}')
121 | else:
122 | from fabulous import image as fabimg
123 |
124 | print(fabimg.Image(f'{img_path}'))
125 | else:
126 | os.startfile(img_path) # windows
127 | ans = input('\n请输入验证码:')
128 | os.remove(img_path)
129 | return ans
130 |
131 |
132 | def text_align(text, length) -> str:
133 | """中英混合字符串对齐"""
134 | text_len = len(text)
135 | for char in text:
136 | if u'\u4e00' <= char <= u'\u9fff':
137 | text_len += 1
138 | space = length - text_len
139 | return text + ' ' * space
140 |
141 |
142 | def parsing_up_params(arg: str, follow, force, mkdir) -> (bool, bool, bool, bool):
143 | """解析文件上传参数
144 | :param str arg: 解析参数
145 | :param bool follow: 实时任务
146 | :param bool force: 强制上传
147 | :param bool mkdir: 不创建父文件夹
148 | :return: follow, force, mkdir, match(标识是否需要删除 arg)
149 | """
150 | match = False
151 | if len(arg) > 1:
152 | if arg.startswith('--'):
153 | if arg == '--follow': # 实时任务
154 | follow = True
155 | match = True
156 | elif arg == '--force': # 强制上传
157 | force = True
158 | match = True
159 | elif arg == '--nodir': # 不创建父文件夹
160 | mkdir = False
161 | match = True
162 | elif arg.startswith('-'):
163 | for i in arg[1:]:
164 | if i == 'f': # 实时任务
165 | follow = True
166 | match = True
167 | elif i == 'F': # 强制上传
168 | force = True
169 | match = True
170 | elif i == 'n': # 不创建父文件夹
171 | mkdir = False
172 | match = True
173 | return follow, force, mkdir, match
174 |
175 |
176 | def handle_name(name: str) -> str:
177 | """使用引号包裹有空格的文件名"""
178 | if ' ' in name:
179 | name = "'" + name + "'"
180 | return name
181 |
182 |
183 | def handle_args(args: str) -> list:
184 | '''处理参数列表,返回参数列表'''
185 | result = []
186 | arg = ''
187 | i = 0
188 | flag_1 = False # 标记 "
189 | flag_2 = False # 标记 '
190 | while i < len(args):
191 | if flag_1 and args[i] != '"':
192 | arg += args[i]
193 | elif flag_2 and args[i] != '\'':
194 | arg += args[i]
195 | elif args[i] not in (' ', '\\', '"', '\''):
196 | arg += args[i]
197 | elif args[i] == '\\' and i < len(args) and args[i + 1] in (' ', '"', '\''):
198 | arg += args[i + 1]
199 | i += 1 # 额外前进一步
200 | elif args[i] == ' ':
201 | if arg:
202 | result.append(arg)
203 | arg = '' # 新的参数
204 | elif args[i] == '"':
205 | if flag_2: # ' some"s thing ' "other params"
206 | arg += args[i]
207 | else:
208 | flag_1 = not flag_1
209 | elif args[i] == '\'':
210 | if flag_1: # " some's thing " 'other params'
211 | arg += args[i]
212 | else:
213 | flag_2 = not flag_2
214 | i += 1
215 | if arg:
216 | result.append(arg)
217 | return result
218 |
219 |
220 | def set_completer(choice_list, *, cmd_list=None, condition=None):
221 | """设置自动补全"""
222 | if condition is None:
223 | condition = lambda typed, choice: choice.startswith(typed) or choice.startswith("'" + typed) # 默认筛选条件:选项以键入字符开头
224 |
225 | def completer(typed, rank):
226 | tab_list = [] # TAB 补全的选项列表
227 | if cmd_list is not None and not typed: # 内置命令提示
228 | return cmd_list[rank]
229 |
230 | for choice in choice_list:
231 | if condition(typed, choice):
232 | tab_list.append(choice)
233 | return tab_list[rank]
234 |
235 | readline.parse_and_bind("tab: complete")
236 | readline.set_completer(completer)
237 |
238 |
239 | def print_logo():
240 | """输出logo"""
241 | if _ERR: # 有错误就不清屏不打印 logo
242 | return None
243 | else:
244 | clear_screen()
245 | logo_str = f"""
246 | # /$$$$$$ /$$ /$$ /$$ /$$$$$$ /$$$$$$
247 | # /$$__ $$| $$ | $$ /$$$$ /$$__ $$ /$$__ $$
248 | # | $$ \__/| $$ /$$$$$$ /$$ /$$ /$$$$$$$|_ $$ | $$ \ $$| $$ \ $$
249 | # | $$ | $$ /$$__ $$| $$ | $$ /$$__ $$ | $$ | $$$$$$/| $$$$$$$
250 | # | $$ | $$| $$ \ $$| $$ | $$| $$ | $$ | $$ >$$__ $$ \____ $$
251 | # | $$ $$| $$| $$ | $$| $$ | $$| $$ | $$ | $$ | $$ \ $$ /$$ \ $$
252 | # | $$$$$$/| $$| $$$$$$/| $$$$$$/| $$$$$$$ /$$$$$$| $$$$$$/| $$$$$$/
253 | # \______/ |__/ \______/ \______/ \_______/|______/ \______/ \______/
254 | #
255 | --------------------------------------------------------------------------
256 | Github: https://github.com/{GIT_REPO} (Version: {version})
257 | --------------------------------------------------------------------------
258 | """
259 | print(logo_str)
260 |
261 |
262 | def print_help():
263 | # clear_screen()
264 | help_text = f""" cloud189-cli | 天翼云盘客户端 for {M_platform} | v{version}
265 | • 支持文件秒传,文件夹保持相对路径上传
266 | • 获取文件分享链接,批量上传下载,断点续传等功能
267 |
268 | 命令帮助 :
269 | help 显示本信息
270 | update 检查更新
271 | *rmode 屏幕阅读器模式
272 | refresh/r 强制刷新文件列表
273 | login 使用账号密码登录网盘/添加用户
274 | clogin 使用 Cookie 登录网盘/添加用户
275 | *logout 删除当前用户 Cookie/删除指定用户
276 | su 列出、切换账户
277 | jobs/j 查看后台任务列表
278 | ls 列出文件(夹),仅文件名
279 | ll 列出文件(夹),详细
280 | cd 切换工作目录
281 | cdrec 进入回收站目录
282 | rm 彻底删除文件
283 | rec 恢复文件
284 | clean 清空回收站
285 | cd .. 退出回收站
286 | rm 删除网盘文件(夹)
287 | rename 重命名文件(夹)
288 | mv 移动文件(夹)
289 | mkdir 创建新文件夹
290 | share 显示文件(夹)分享信息
291 | shared 显示已经分享的文件(夹)信息
292 | clear/c 清空屏幕
293 | upload/u 上传文件(夹)
294 | down/d 下载文件、提取分享链接直链 # TODO: 下载文件夹
295 | setpath 设置文件下载路径
296 | who/quota 查看当前账户信息
297 | sign 签到+抽奖
298 | bye/exit/b 退出本程序
299 |
300 | * 表示目前版本无法使用。
301 | 更详细的介绍请参考本项目的 Github 主页:
302 | https://github.com/{GIT_REPO}
303 | 如有 Bug 反馈或建议请在 GitHub 提 Issue
304 | 感谢您的使用 (●'◡'●)
305 | """
306 | print(help_text)
307 |
308 |
309 | def check_update():
310 | """检查更新"""
311 | print("正在检测更新...")
312 | api = f"https://api.github.com/repos/{GIT_REPO}/releases/latest"
313 | tag_name = None
314 | try:
315 | resp = requests.get(api, timeout=3).json()
316 | tag_name, msg = resp['tag_name'], resp['body']
317 | except (requests.RequestException, AttributeError, KeyError) as err:
318 | error(f"检查更新时发生异常,可能是 GitHub 间歇性无法访问!\n{err=}")
319 | return None
320 | if tag_name:
321 | ver = version.split('.')
322 | ver2 = tag_name.replace('v', '').split('.')
323 | local_version = int(ver[0]) * 100 + int(ver[1]) * 10 + int(ver[2])
324 | remote_version = int(ver2[0]) * 100 + int(ver2[1]) * 10 + int(ver2[2])
325 | if remote_version > local_version:
326 | print(f"程序可以更新 v{version} -> {tag_name}")
327 | print(f"\n@更新说明:\n{msg}")
328 | print("\n@Linux 更新:")
329 | input(f"git clone --depth=1 https://github.com/{GIT_REPO}.git")
330 | else:
331 | print("(*/ω\*) 暂无新版本发布~")
332 | print("但项目可能已经更新,建议去项目主页看看")
333 | print("如有 Bug 或建议,请提 Issue")
334 | print(f"Github: https://github.com/{GIT_REPO}")
335 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding=utf-8 -*-
3 |
4 | import sys
5 | from cloud189.cli.cli import Commander
6 | from cloud189.cli.utils import print_logo, check_update, error
7 |
8 | if __name__ == '__main__':
9 | commander = Commander()
10 | commander.login(("--auto", ))
11 |
12 | if len(sys.argv) >= 2:
13 | cmd, args = (sys.argv[1], []) if len(sys.argv) == 2 else (sys.argv[1], [*sys.argv[2:]])
14 | commander.run_one(cmd, args)
15 | else:
16 | check_update()
17 | print_logo()
18 | while True:
19 | try:
20 | commander.run()
21 | except KeyboardInterrupt:
22 | pass
23 | except Exception as e:
24 | error(e)
25 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | certifi
2 | chardet
3 | idna
4 | # 只有 Windows 用户需要安装 pyreadline
5 | # *nix 系统 Python 包含有 readline 模块
6 | pyreadline
7 | requests
8 | requests_toolbelt
9 | rsa
10 | simplejson
11 | six
12 | #添加可以在Linux headless模式下输入验证码的图形库
13 | fabulous
14 | Pillow
15 |
--------------------------------------------------------------------------------