From 68f1589df705ab708f33fe9f170ab82cf9c29ee0 Mon Sep 17 00:00:00 2001 From: zaxtyson Date: Fri, 1 May 2020 13:11:15 +0800 Subject: [PATCH] follow official limits --- README.md | 135 ++++ build_exe.spec | 41 ++ lanzou/__init__.py | 1 + lanzou/api/__init__.py | 5 + lanzou/api/core.py | 1227 ++++++++++++++++++++++++++++++++++++ lanzou/api/models.py | 100 +++ lanzou/api/types.py | 18 + lanzou/api/utils.py | 192 ++++++ lanzou/cmder/__init__.py | 5 + lanzou/cmder/cmder.py | 455 +++++++++++++ lanzou/cmder/config.py | 84 +++ lanzou/cmder/downloader.py | 216 +++++++ lanzou/cmder/manager.py | 85 +++ lanzou/cmder/recovery.py | 109 ++++ lanzou/cmder/utils.py | 193 ++++++ lanzou_cmd.py | 17 + logo.ico | Bin 0 -> 107593 bytes user.dat | Bin 0 -> 137 bytes 18 files changed, 2883 insertions(+) create mode 100644 README.md create mode 100644 build_exe.spec create mode 100644 lanzou/__init__.py create mode 100644 lanzou/api/__init__.py create mode 100644 lanzou/api/core.py create mode 100644 lanzou/api/models.py create mode 100644 lanzou/api/types.py create mode 100644 lanzou/api/utils.py create mode 100644 lanzou/cmder/__init__.py create mode 100644 lanzou/cmder/cmder.py create mode 100644 lanzou/cmder/config.py create mode 100644 lanzou/cmder/downloader.py create mode 100644 lanzou/cmder/manager.py create mode 100644 lanzou/cmder/recovery.py create mode 100644 lanzou/cmder/utils.py create mode 100644 lanzou_cmd.py create mode 100644 logo.ico create mode 100644 user.dat diff --git a/README.md b/README.md new file mode 100644 index 0000000..934a209 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +

+ +

+ +

- 蓝奏云CMD -

+ +

+ + + +

+ +# 界面 + +![cmd.png](https://upload.cc/i1/2020/04/04/W8GKsr.png) + +# 说明 +- 请使用 Python 3.8+ 运行 +- 解除官方上传限制,支持批量上传下载 +- 为了方便管理,API 独立为一个项目[LanZouCloud-API](https://github.com/zaxtyson/LanZouCloud-API) +- 如果 Windows 平台缺少 `readline`,请执行 `pip install pyreadline` +- 默认下载路径为 `./Download`,请使用 `setpath` 命令修改 +- 默认分卷大小为 100 MB, 会员用户请使用 `setsize` 命令修改 +- 未登录时可使用 `down URL` 的方式下载文件(夹)~ +- 关注本页面以获取更新,如果有问题或者建议,请提 issue +- 如果喜欢本项目,请给一个 star (^▽^)/ +- 详细介绍请移步 [Wiki](https://github.com/zaxtyson/LanZouCloud-CMD/wiki) 页面 + +# 下载 +- 感谢 [rachpt](https://github.com/rachpt/lanzou-gui) 开发的 GUI 版本,[点我](https://github.com/rachpt/lanzou-gui/wiki)查看详情 + +- 在蓝奏云网盘下载 [Windows版](https://www.lanzous.com/b0f14h1od) + +- 或者在本项目的 [`releases`](https://github.com/zaxtyson/LanZouCloud-CMD/releases) 板块下载 + +# 更新日志 +## `v2.5.0` +- 遵守官方限制, 不再支持大文件上传和文件名伪装功能(之前上传的文件仍可以正常下载) +- 登录接口被限制, 使用 Cookie 登录, 参见 `v2.3.5` 更新日志 + +## `v2.4.5` +- 修复无法处理蓝奏云自定义域名的问题 +- 修复新用户执行与创建文件夹相关命令时崩溃的问题 +- 新增 `setpasswd` 命令设置文件(夹)默认提取码 +- 新增 `setdelay` 命令设置大文件数据块上传延时,减小被封的可能性 +- 出于 PD 事件的影响,这将是本项目最后一次更新 +- CMD 版本将去除大文件上传功能,仅保留蓝奏云的基本功能 +- API 保留了相关功能,有能力者请自行开发,但是您需要承担由此带来的风险 +- **本项目的代码会在一段时间后删除**,在此之前,请保存好您的网盘的大文件 + +## `v2.4.4` +- `ls` 命令显示文件下载次数 +- 修复 VIP 用户分享链接无法处理的问题 +- 修复下载时可能出现的 Read time out 异常 +- 修复上传大文件自动创建文件夹名包含 `mkdir` 字符串后缀的问题(这不是feature,只是测试时无意中写到代码里了-_-) +- Windows 发行版使用 Inno Setup 封装,直接安装,方便更新 + +## `v2.4.3` +- 上传/下载支持断点续传,大文件续传使用 `filename.record` 文件保存进度,请不要手动修改和删除 +- 新增 `jobs` 命令查看后台任务, 支持提交多个上传/下载任务,使用 `jobs PID` 查看任务详情 +- 新增 `xghost` 命令用于清理网盘中的"幽灵文件夹"(不在网盘和回收站显示的文件夹,移动文件时可以看见,文件移进去就丢失) +- 遇到下载验证码时自动打开图片,要求用户输入验证码 +- 修复了其它的细节问题 + +## `2.4.2` +- 紧急修复了蓝奏云网页端变化导致无法显示文件夹的 Bug + +## `v2.4.1` +- 修复使用 URL 下载大文件失败的问题 +- 修复上传小文件时没有去除非法字符的问题 +- 新增 `rmode` 命令,以适宜屏幕阅读器阅读的方式显示 + +## `v2.4.0` +- 放弃分段压缩,使用更复杂的方式上传大文件。分段数据文件名、文件大小、文件后缀随机,下载时自动处理。 +- 放弃使用修改文件名的方式绕过上传格式限制。上传的文件末尾被添加了 512 字节的信息,储存真实文件名, +下载时自动检测并截断,不会影响文件 hash。一般情况下,不截断此信息不影响文件的使用,但纯文本类文件会受影响(比如代码文件), +建议压缩后上传。 +- 现在可以在网盘不同路径下创建同名文件夹,不再添加 `_` 区分,移动文件时支持绝对路径补全。 +- 上传/下载失败会立即提醒并显示原因,不需要等待全部任务完成。 +- 回收站稍微好看了点~ + +## `v2.3.5` 更新说明 +- 修复回收站文件夹中文件名过长,导致后缀丢失,程序闪退的问题 [#14](https://github.com/zaxtyson/LanZouCloud-CMD/issues/14) +- 修复官方启用滑动验证导致无法登录的问题 [#15](https://github.com/zaxtyson/LanZouCloud-CMD/issues/15) +- 新增 `clogin` 命令支持使用 `cookie` 登录(防止某天 `login` 完全失效) + - Cookie 内容见浏览器地址栏前的🔒 (Chrome): + - `woozooo.com -> Cookie -> ylogin` + - `pc.woozooo.com -> Cookie -> phpdisk_info` +- 因为使用 `Python3.8.1 X64` 打包,导致程序大了一圈😭,您可以使用 `Pyinstaller` 自行打包 + +## `v2.3.4` 更新说明 +- 新增 `update` 命令检查更新(每次启动会检查一次) +- 解除了官方对上传分卷文件的限制 [#11](https://github.com/zaxtyson/LanZouCloud-CMD/issues/11) [#12](https://github.com/zaxtyson/LanZouCloud-CMD/issues/12) +- `rename` 命令支持会员用户修改文件名 [#9](https://github.com/zaxtyson/LanZouCloud-CMD/issues/9) +- 新增 `setsize` 命令支持会员用户修改分卷大小 [#9](https://github.com/zaxtyson/LanZouCloud-CMD/issues/9) +- `mv` 命令支持移动文件夹(不含子文件夹) +- 支持 `cd /` 返回根目录, `cd -` 返回上一次工作目录 [#8](https://github.com/zaxtyson/LanZouCloud-CMD/issues/8) +- 修复了某些特殊情况下回收站崩溃的问题 +- `ls` 命令在文件描述为中英文混合时能够正确对齐 [#8](https://github.com/zaxtyson/LanZouCloud-CMD/issues/8) +- 下载时可以使用 `Ctrl + C` 强行中断 +- 修复文件上传时间的错误 + + +## `v2.3.3` 更新说明 +- 修复上传超过 1GB 的文件时,前 10 个分卷丢失的 Bug [#7](https://github.com/zaxtyson/LanZouCloud-CMD/issues/7) + +## `v2.3.2` 更新说明 +- 修复了无法上传的 Bug +- 解除了官方对文件名包含多个后缀的限制 +- 使用 cookie 登录,配置文件不再保存明文 + +## `v2.3.1` 更新说明 +- 界面焕然一新 +- 修复了一堆 BUG +- 新增设置描述信息功能 +- 完善了回收站功能 +- 完善了移动文件功能 + +## `v2.2.1` 更新说明 +- 修复了文件(夹)无法下载的问题 [#4](https://github.com/zaxtyson/LanZouCloud-CMD/issues/4) +- 修复了上传 rar 分卷文件被 ban 的问题 +- 修复了无后缀文件上传出错的问题 +- 修复了文件中空白字符导致上传和解压失败的问题 + +## `v2.1` 更新说明 +- 修复了蓝奏云分享链接格式变化导致无法获取直链的问题 + +## `v2.0`更新说明 +- 修复了登录 `formhash` 的错误 +- 增加了上传/下载的进度条 [#1](https://github.com/zaxtyson/LanZouCloud-CMD/issues/1) +- 使用 RAR 分卷压缩代替文件分段 [#2](https://github.com/zaxtyson/LanZouCloud-CMD/issues/2) +- 修复了连续上传大文件被ban的问题 [#3](https://github.com/zaxtyson/LanZouCloud-CMD/issues/3) +- 增加了回收站功能 +- 取消了`种子文件`下载方式,自动识别分卷数据并解压 +- 增加了通过分享链接下载的功能 diff --git a/build_exe.spec b/build_exe.spec new file mode 100644 index 0000000..0d00fd7 --- /dev/null +++ b/build_exe.spec @@ -0,0 +1,41 @@ +# -*- mode: python ; coding: utf-8 -*- + +# 本文件用于打包 Windows 程序 +# 建议在虚拟环境下打包 +# pyinstaller --clean -F build_exe.spec + +block_cipher = None + + +a = Analysis(['lanzou_cmd.py'], + pathex=['.'], + binaries=[], + datas=[('user.dat','.')], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=['PyInstaller', 'pip', 'setuptools', 'altgraph','future','pefile', 'pywin32-ctypes'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='lanzou-cmd', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True , icon='logo.ico') +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='lanzou-cmd') diff --git a/lanzou/__init__.py b/lanzou/__init__.py new file mode 100644 index 0000000..6f0c462 --- /dev/null +++ b/lanzou/__init__.py @@ -0,0 +1 @@ +__all__ = ['api', 'cmder'] diff --git a/lanzou/api/__init__.py b/lanzou/api/__init__.py new file mode 100644 index 0000000..a0811f3 --- /dev/null +++ b/lanzou/api/__init__.py @@ -0,0 +1,5 @@ +from lanzou.api.core import LanZouCloud + +version = '2.5.0' + +__all__ = ['utils', 'types', 'models', 'LanZouCloud', 'version'] diff --git a/lanzou/api/core.py b/lanzou/api/core.py new file mode 100644 index 0000000..ddd6ed0 --- /dev/null +++ b/lanzou/api/core.py @@ -0,0 +1,1227 @@ +""" +蓝奏网盘 API,封装了对蓝奏云的各种操作,解除了上传格式、大小限制 +""" + +import os +import pickle +import re +import shutil +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from random import shuffle, random, uniform +from time import sleep +from typing import List + +import requests +from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor +from urllib3 import disable_warnings +from urllib3.exceptions import InsecureRequestWarning + +from lanzou.api.models import FileList, FolderList +from lanzou.api.types import * +from lanzou.api.utils import * + +__all__ = ['LanZouCloud'] + + +class LanZouCloud(object): + FAILED = -1 + SUCCESS = 0 + ID_ERROR = 1 + PASSWORD_ERROR = 2 + LACK_PASSWORD = 3 + ZIP_ERROR = 4 + MKDIR_ERROR = 5 + URL_INVALID = 6 + FILE_CANCELLED = 7 + PATH_ERROR = 8 + NETWORK_ERROR = 9 + CAPTCHA_ERROR = 10 + OFFICIAL_LIMITED = 11 + + def __init__(self): + self._session = requests.Session() + self._captcha_handler = None + self._limit_mode = True # 是否保持官方限制 + self._timeout = 15 # 每个请求的超时(不包含下载响应体的用时) + self._max_size = 100 # 单个文件大小上限 MB + self._upload_delay = (0, 0) # 文件上传延时 + self._host_url = 'https://www.lanzous.com' + self._doupload_url = 'https://pc.woozooo.com/doupload.php' + self._account_url = 'https://pc.woozooo.com/account.php' + self._mydisk_url = 'https://pc.woozooo.com/mydisk.php' + self._cookies = None + self._headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', + 'Referer': 'https://www.lanzous.com', + 'Accept-Language': 'zh-CN,zh;q=0.9', # 提取直连必需设置这个,否则拿不到数据 + } + disable_warnings(InsecureRequestWarning) # 全局禁用 SSL 警告 + + def _get(self, url, **kwargs): + try: + kwargs.setdefault('timeout', self._timeout) + kwargs.setdefault('headers', self._headers) + return self._session.get(url, verify=False, **kwargs) + except (ConnectionError, requests.RequestException): + return None + + def _post(self, url, data, **kwargs): + try: + kwargs.setdefault('timeout', self._timeout) + kwargs.setdefault('headers', self._headers) + return self._session.post(url, data, verify=False, **kwargs) + except (ConnectionError, requests.RequestException): + return None + + def ignore_limits(self): + """解除官方限制""" + logger.warning("*** You have enabled the big file upload and filename disguise features ***") + logger.warning("*** This means that you fully understand what may happen and still agree to take the risk ***") + self._limit_mode = False + + def set_max_size(self, max_size=100) -> int: + """设置单文件大小限制(会员用户可超过 100M)""" + if max_size < 100: + return LanZouCloud.FAILED + self._max_size = max_size + return LanZouCloud.SUCCESS + + def set_upload_delay(self, t_range: tuple) -> int: + """设置上传大文件数据块时,相邻两次上传之间的延时,减小被封号的可能""" + if 0 <= t_range[0] <= t_range[1]: + self._upload_delay = t_range + return LanZouCloud.SUCCESS + return LanZouCloud.FAILED + + def set_captcha_handler(self, captcha_handler): + """设置下载验证码处理函数 + :param captcha_handler (img_data) -> str 参数为图片二进制数据,需返回验证码字符 + """ + self._captcha_handler = captcha_handler + + def login(self, username, passwd) -> int: + """登录蓝奏云控制台""" + login_data = {"action": "login", "task": "login", "setSessionId": "", "setToken": "", "setSig": "", + "setScene": "", "username": username, "password": passwd} + phone_header = { + "User-Agent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/82.0.4051.0 Mobile Safari/537.36"} + html = self._get(self._account_url) + if not html: + return LanZouCloud.NETWORK_ERROR + formhash = re.findall(r'name="formhash" value="(.+?)"', html.text) + if not formhash: + return LanZouCloud.FAILED + login_data['formhash'] = formhash[0] + html = self._post(self._account_url, login_data, headers=phone_header) + if not html: + return LanZouCloud.NETWORK_ERROR + if '登录成功' in html.text: + self._cookies = html.cookies.get_dict() + return LanZouCloud.SUCCESS + else: + return LanZouCloud.FAILED + + def get_cookie(self) -> dict: + """获取用户 Cookie""" + return self._cookies + + def login_by_cookie(self, cookie: dict) -> int: + """通过cookie登录""" + self._session.cookies.update(cookie) + html = self._get(self._account_url) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.FAILED if '网盘用户登录' in html.text else LanZouCloud.SUCCESS + + def logout(self) -> int: + """注销""" + html = self._get(self._account_url, params={'action': 'logout'}) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '退出系统成功' in html.text else LanZouCloud.FAILED + + def delete(self, fid, is_file=True) -> int: + """把网盘的文件、无子文件夹的文件夹放到回收站""" + post_data = {'task': 6, 'file_id': fid} if is_file else {'task': 3, 'folder_id': fid} + result = self._post(self._doupload_url, post_data) + if not result: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED + + def clean_rec(self) -> int: + """清空回收站""" + post_data = {'action': 'delete_all', 'task': 'delete_all'} + html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) + if not html: + return LanZouCloud.NETWORK_ERROR + post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', html.text)[0] # 设置表单 hash + html = self._post(self._mydisk_url + '?item=recycle', post_data) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '清空回收站成功' in html.text else LanZouCloud.FAILED + + def get_rec_dir_list(self) -> FolderList: + """获取回收站文件夹列表""" + # 回收站中文件(夹)名只能显示前 17 个中文字符或者 34 个英文字符,如果这些字符相同,则在文件(夹)名后添加 (序号) ,以便区分 + html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) + if not html: + return FolderList() + dirs = re.findall(r'folder_id=(\d+).+?> (.+?)\.{0,3}.*\n+.*(.+?).*\n.*(.+?)', + html.text) + all_dir_list = FolderList() # 文件夹信息列表 + dir_name_list = [] # 文件夹名列表d + counter = 1 # 重复计数器 + for fid, name, size, time in dirs: + if name in dir_name_list: # 文件夹名前 17 个中文或 34 个英文重复 + counter += 1 + name = f'{name}({counter})' + else: + counter = 1 + dir_name_list.append(name) + all_dir_list.append(RecFolder(name, int(fid), size, time, None)) + return all_dir_list + + def get_rec_file_list(self, folder_id=-1) -> FileList: + """获取回收站文件列表""" + if folder_id == -1: # 列出回收站根目录文件 + # 回收站文件夹中的文件也会显示在根目录 + html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) + if not html: + return FileList() + html = remove_notes(html.text) + files = re.findall( + r'fl_sel_ids[^\n]+value="(\d+)".+?filetype/(\w+)\.gif.+?/>\s?(.+?)(?:\.{3})?.+?([\d\-]+?)', + html, re.DOTALL) + file_list = FileList() + file_name_list = [] + counter = 1 + for fid, ftype, name, time in sorted(files, key=lambda x: x[2]): + if not name.endswith(ftype): # 防止文件名太长导致丢失了文件后缀 + name = name + '.' + ftype + + if name in file_name_list: # 防止长文件名前 17:34 个字符相同重名 + counter += 1 + name = f'{name}({counter})' + else: + counter = 1 + file_name_list.append(name) + file_list.append(RecFile(name, int(fid), ftype, size='', time=time)) + return file_list + else: # 列出回收站中文件夹内的文件,信息只有部分文件名和文件大小 + para = {'item': 'recycle', 'action': 'folder_restore', 'folder_id': folder_id} + html = self._get(self._mydisk_url, params=para) + if not html or '此文件夹没有包含文件' in html.text: + return FileList() + html = remove_notes(html.text) + files = re.findall( + r'com/(\d+?)".+?filetype/(\w+)\.gif.+?/> (.+?)(?:\.{3})? \((.+?)\)', + html) + file_list = FileList() + file_name_list = [] + counter = 1 + for fid, ftype, name, size in sorted(files, key=lambda x: x[2]): + if not name.endswith(ftype): # 防止文件名太长丢失后缀 + name = name + '.' + ftype + if name in file_name_list: + counter += 1 + name = f'{name}({counter})' # 防止文件名太长且前17个字符重复 + else: + counter = 1 + file_name_list.append(name) + file_list.append(RecFile(name, int(fid), ftype, size=size, time='')) + return file_list + + def get_rec_all(self): + """获取整理后回收站的所有信息""" + root_files = self.get_rec_file_list() # 回收站根目录文件列表 + folder_list = FolderList() # 保存整理后的文件夹列表 + for folder in self.get_rec_dir_list(): # 遍历所有子文件夹 + this_folder = RecFolder(folder.name, folder.id, folder.size, folder.time, FileList()) + for file in self.get_rec_file_list(folder.id): # 文件夹内的文件属性: name,id,type,size + if root_files.find_by_id(file.id): # 根目录存在同名文件 + file_time = root_files.pop_by_id(file.id).time # 从根目录删除, time 信息用来补充文件夹中的文件 + file = file._replace(time=file_time) # 不能直接更新 namedtuple, 需要 _replace + this_folder.files.append(file) + else: # 根目录没有同名文件(用户手动删了),文件还在文件夹中,只是根目录不显示,time 信息无法补全了 + file = file._replace(time=folder.time) # 那就设置时间为文件夹的创建时间 + this_folder.files.append(file) + folder_list.append(this_folder) + return root_files, folder_list + + def delete_rec(self, fid, is_file=True) -> int: + """彻底删除回收站文件(夹)""" + # 彻底删除后需要 1.5s 才能调用 get_rec_file() ,否则信息没有刷新,被删掉的文件似乎仍然 "存在" + if is_file: + para = {'item': 'recycle', 'action': 'file_delete_complete', 'file_id': fid} + post_data = {'action': 'file_delete_complete', 'task': 'file_delete_complete', 'file_id': fid} + else: + para = {'item': 'recycle', 'action': 'folder_delete_complete', 'folder_id': fid} + post_data = {'action': 'folder_delete_complete', 'task': 'folder_delete_complete', 'folder_id': fid} + + html = self._get(self._mydisk_url, params=para) + if not html: + return LanZouCloud.NETWORK_ERROR + # 此处的 formhash 与 login 时不同,不要尝试精简这一步 + post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash + html = self._post(self._mydisk_url + '?item=recycle', post_data) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '删除成功' in html.text else LanZouCloud.FAILED + + def delete_rec_multi(self, *, files=None, folders=None) -> int: + """彻底删除回收站多个文件(夹) + :param files 文件 id 列表 List[int] + :param folders 文件夹 id 列表 List[int] + """ + if not files and not folders: + return LanZouCloud.FAILED + para = {'item': 'recycle', 'action': 'files'} + post_data = {'action': 'files', 'task': 'delete_complete_recycle'} + if folders: + post_data['fd_sel_ids[]'] = folders + if files: + post_data['fl_sel_ids[]'] = files + html = self._get(self._mydisk_url, params=para) + if not html: + return LanZouCloud.NETWORK_ERROR + post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash + html = self._post(self._mydisk_url + '?item=recycle', post_data) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '删除成功' in html.text else LanZouCloud.FAILED + + def recovery(self, fid, is_file=True) -> int: + """从回收站恢复文件""" + if is_file: + para = {'item': 'recycle', 'action': 'file_restore', 'file_id': fid} + post_data = {'action': 'file_restore', 'task': 'file_restore', 'file_id': fid} + else: + para = {'item': 'recycle', 'action': 'folder_restore', 'folder_id': fid} + post_data = {'action': 'folder_restore', 'task': 'folder_restore', 'folder_id': fid} + html = self._get(self._mydisk_url, params=para) + if not html: + return LanZouCloud.NETWORK_ERROR + post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash + html = self._post(self._mydisk_url + '?item=recycle', post_data) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '恢复成功' in html.text else LanZouCloud.FAILED + + def recovery_multi(self, *, files=None, folders=None) -> int: + """从回收站恢复多个文件(夹)""" + if not files and not folders: + return LanZouCloud.FAILED + para = {'item': 'recycle', 'action': 'files'} + post_data = {'action': 'files', 'task': 'restore_recycle'} + if folders: + post_data['fd_sel_ids[]'] = folders + if files: + post_data['fl_sel_ids[]'] = files + html = self._get(self._mydisk_url, params=para) + if not html: + return LanZouCloud.NETWORK_ERROR + post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', html.text)[0] # 设置表单 hash + html = self._post(self._mydisk_url + '?item=recycle', post_data) + if not html: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '恢复成功' in html.text else LanZouCloud.FAILED + + def recovery_all(self) -> int: + """从回收站恢复所有文件(夹)""" + para = {'item': 'recycle', 'action': 'restore_all'} + post_data = {'action': 'restore_all', 'task': 'restore_all'} + first_page = self._get(self._mydisk_url, params=para) + if not first_page: + return LanZouCloud.NETWORK_ERROR + post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', first_page.text)[0] # 设置表单 hash + second_page = self._post(self._mydisk_url + '?item=recycle', post_data) + if not second_page: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if '还原成功' in second_page.text else LanZouCloud.FAILED + + def get_file_list(self, folder_id=-1) -> FileList: + """获取文件列表""" + page = 1 + file_list = FileList() + while True: + post_data = {'task': 5, 'folder_id': folder_id, 'pg': page} + resp = self._post(self._doupload_url, post_data) + if not resp: # 网络异常,重试 + continue + else: + resp = resp.json() + if resp["info"] == 0: + break # 已经拿到了全部的文件信息 + else: + page += 1 # 下一页 + # 文件信息处理 + for file in resp["text"]: + file_list.append(File( + id=int(file['id']), + name=file['name_all'], + time=time_format(file['time']), # 上传时间 + size=file['size'], # 文件大小 + type=file['name_all'].split('.')[-1], # 文件类型 + downs=int(file['downs']), # 下载次数 + has_pwd=True if int(file['onof']) == 1 else False, # 是否存在提取码 + has_des=True if int(file['is_des']) == 1 else False # 是否存在描述 + )) + return file_list + + def get_dir_list(self, folder_id=-1) -> FolderList: + """获取子文件夹列表""" + folder_list = FolderList() + post_data = {'task': 47, 'folder_id': folder_id} + resp = self._post(self._doupload_url, post_data) + if not resp: + return folder_list + for folder in resp.json()['text']: + folder_list.append( + Folder( + id=int(folder['fol_id']), + name=folder['name'], + has_pwd=True if folder['onof'] == 1 else False, + desc=folder['folder_des'].strip('[]') + )) + return folder_list + + def clean_ghost_folders(self): + """清除网盘中的幽灵文件夹""" + + # 可能有一些文件夹,网盘和回收站都看不见它,但是它确实存在,移动文件夹时才会显示 + # 如果不清理掉,不小心将文件移动进去就完蛋了 + def _clean(fid): + for folder in self.get_dir_list(fid): + real_folders.append(folder) + _clean(folder.id) + + folder_with_ghost = self.get_move_folders() + folder_with_ghost.pop_by_id(-1) # 忽视根目录 + real_folders = FolderList() + _clean(-1) + for folder in folder_with_ghost: + if not real_folders.find_by_id(folder.id): + logger.debug(f"Delete ghost folder: {folder.name} #{folder.id}") + if self.delete(folder.id, False) != LanZouCloud.SUCCESS: + return LanZouCloud.FAILED + if self.delete_rec(folder.id, False) != LanZouCloud.SUCCESS: + return LanZouCloud.FAILED + return LanZouCloud.SUCCESS + + def get_full_path(self, folder_id=-1) -> FolderList: + """获取文件夹完整路径""" + path_list = FolderList() + path_list.append(FolderId('LanZouCloud', -1)) + post_data = {'task': 47, 'folder_id': folder_id} + resp = self._post(self._doupload_url, post_data) + if not resp: + return path_list + for folder in resp.json()['info']: + if folder['folderid'] and folder['name']: # 有时会返回无效数据, 这两个字段中某个为 None + path_list.append(FolderId(id=int(folder['folderid']), name=folder['name'])) + return path_list + + def _captcha_recognize(self, file_token): + """识别下载时弹出的验证码,返回下载直链 + :param file_token 文件的标识码,每次刷新会变化 + """ + if not self._captcha_handler: # 必需提前设置验证码处理函数 + logger.debug(f"Not set captcha handler function!") + return None + + get_img_api = 'https://vip.d0.baidupan.com/file/imagecode.php?r=' + str(random()) + img_data = self._get(get_img_api).content + captcha = self._captcha_handler(img_data) # 用户手动识别验证码 + post_code_api = 'https://vip.d0.baidupan.com/file/ajax.php' + post_data = {'file': file_token, 'bm': captcha} + resp = self._post(post_code_api, post_data) + if not resp or resp.json()['zt'] != 1: + logger.debug(f"Captcha ERROR: {captcha}") + return None + logger.debug(f"Captcha PASS: {captcha}") + return resp.json()['url'] + + def get_file_info_by_url(self, share_url, pwd='') -> FileDetail: + """获取文件各种信息(包括下载直链) + :param share_url: 文件分享链接 + :param pwd: 文件提取码(如果有的话) + """ + if not is_file_url(share_url): # 非文件链接返回错误 + return FileDetail(LanZouCloud.URL_INVALID, pwd=pwd, url=share_url) + + first_page = self._get(share_url) # 文件分享页面(第一页) + if not first_page: + return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url) + + first_page = remove_notes(first_page.text) # 去除网页里的注释 + if '文件取消' in first_page: + return FileDetail(LanZouCloud.FILE_CANCELLED, pwd=pwd, url=share_url) + + # 这里获取下载直链 304 重定向前的链接 + if '输入密码' in first_page: # 文件设置了提取码时 + if len(pwd) == 0: + return FileDetail(LanZouCloud.LACK_PASSWORD, pwd=pwd, url=share_url) # 没给提取码直接退出 + # data : 'action=downprocess&sign=AGZRbwEwU2IEDQU6BDRUaFc8DzxfMlRjCjTPlVkWzFSYFY7ATpWYw_c_c&p='+pwd, + sign = re.search(r"sign=(\w+?)&", first_page).group(1) + post_data = {'action': 'downprocess', 'sign': sign, 'p': pwd} + link_info = self._post(self._host_url + '/ajaxm.php', post_data) # 保存了重定向前的链接信息和文件名 + second_page = self._get(share_url) # 再次请求文件分享页面,可以看见文件名,时间,大小等信息(第二页) + if not link_info or not second_page.text: + return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url) + link_info = link_info.json() + second_page = remove_notes(second_page.text) + # 提取文件信息 + f_name = link_info['inf'] + f_size = re.search(r'大小.+?(\d[\d.]+\s?[BKM]?)<', second_page) + f_size = f_size.group(1) if f_size else '0 M' + f_time = re.search(r'class="n_file_infos">(.+?)', second_page) + f_time = time_format(f_time.group(1)) if f_time else time_format('0 小时前') + f_desc = re.search(r'class="n_box_des">(.*?)', second_page) + f_desc = f_desc.group(1) if f_desc else '' + else: # 文件没有设置提取码时,文件信息都暴露在分享页面上 + para = re.search(r'(.+?) - 蓝奏云", first_page) or \ + re.search(r'
([^<>]+?)
', first_page) or \ + re.search(r'
(.+?)
', first_page) or \ + re.search(r'
([^<>]+?)
', first_page) + f_name = f_name.group(1) if f_name else "未匹配到文件名" + # 匹配文件时间,文件没有时间信息就视为今天,统一表示为 2020-01-01 格式 + f_time = re.search(r'>(\d+\s?[秒天分小][钟时]?前|[昨前]天\s?[\d:]+?|\d+\s?天前|\d{4}-\d\d-\d\d)<', first_page) + f_time = time_format(f_time.group(1)) if f_time else time_format('0 小时前') + # 匹配文件大小 + f_size = re.search(r'大小.+?(\d[\d.]+\s?[BKM]?)<', first_page) + f_size = f_size.group(1) if f_size else '0 M' + f_desc = re.search(r'文件描述.+?
\n?\s*(.*?)\s*', first_page) + f_desc = f_desc.group(1) if f_desc else '' + first_page = self._get(self._host_url + para) + if not first_page: + return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, + pwd=pwd, url=share_url) + first_page = remove_notes(first_page.text) + # 一般情况 sign 的值就在 data 里,有时放在变量后面 + sign = re.search(r"'sign':(.+?),", first_page).group(1) + if len(sign) < 20: # 此时 sign 保存在变量里面, 变量名是 sign 匹配的字符 + sign = re.search(rf"var {sign}\s*=\s*'(.+?)';", first_page).group(1) + post_data = {'action': 'downprocess', 'sign': sign, 'ves': 1} + link_info = self._post(self._host_url + '/ajaxm.php', post_data) + if not link_info: + return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, + pwd=pwd, url=share_url) + else: + link_info = link_info.json() + # 这里开始获取文件直链 + if link_info['zt'] == 1: + fake_url = link_info['dom'] + '/file/' + link_info['url'] # 假直连,存在流量异常检测 + download_page = self._get(fake_url, allow_redirects=False) + if not download_page: + return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, + pwd=pwd, url=share_url) + download_page.encoding = 'utf-8' + if '网络不正常' in download_page.text: # 流量异常,要求输入验证码 + file_token = re.findall(r"'file':'(.+?)'", download_page.text)[0] + direct_url = self._captcha_recognize(file_token) + if not direct_url: + return FileDetail(LanZouCloud.CAPTCHA_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, + pwd=pwd, url=share_url) + else: + direct_url = download_page.headers['Location'] # 重定向后的真直链 + + f_type = f_name.split('.')[-1] + return FileDetail(LanZouCloud.SUCCESS, + name=f_name, size=f_size, type=f_type, time=f_time, + desc=f_desc, pwd=pwd, url=share_url, durl=direct_url) + else: + return FileDetail(LanZouCloud.FAILED, name=f_name, time=f_time, size=f_size, desc=f_desc, pwd=pwd, + url=share_url) + + def get_file_info_by_id(self, file_id) -> FileDetail: + """通过 id 获取文件信息""" + info = self.get_share_info(file_id) + if info.code != LanZouCloud.SUCCESS: + return FileDetail(info.code) + return self.get_file_info_by_url(info.url, info.pwd) + + def get_durl_by_url(self, share_url, pwd='') -> DirectUrlInfo: + """通过分享链接获取下载直链""" + file_info = self.get_file_info_by_url(share_url, pwd) + if file_info.code != LanZouCloud.SUCCESS: + return DirectUrlInfo(file_info.code, '', '') + return DirectUrlInfo(LanZouCloud.SUCCESS, file_info.name, file_info.durl) + + def get_durl_by_id(self, file_id) -> DirectUrlInfo: + """登录用户通过id获取直链""" + info = self.get_share_info(file_id, is_file=True) # 能获取直链,一定是文件 + return self.get_durl_by_url(info.url, info.pwd) + + def get_share_info(self, fid, is_file=True) -> ShareInfo: + """获取文件(夹)提取码、分享链接""" + post_data = {'task': 22, 'file_id': fid} if is_file else {'task': 18, 'folder_id': fid} # 获取分享链接和密码用 + f_info = self._post(self._doupload_url, post_data) + if not f_info: + return ShareInfo(LanZouCloud.NETWORK_ERROR) + else: + f_info = f_info.json()['info'] + + # id 有效性校验 + if ('f_id' in f_info.keys() and f_info['f_id'] == 'i') or ('name' in f_info.keys() and not f_info['name']): + return ShareInfo(LanZouCloud.ID_ERROR) + + # onof=1 时,存在有效的提取码; onof=0 时不存在提取码,但是 pwd 字段还是有一个无效的随机密码 + pwd = f_info['pwd'] if f_info['onof'] == '1' else '' + if 'f_id' in f_info.keys(): # 说明返回的是文件的信息 + url = f_info['is_newd'] + '/' + f_info['f_id'] # 文件的分享链接需要拼凑 + file_info = self._post(self._doupload_url, {'task': 12, 'file_id': fid}) # 文件信息 + if not file_info: + return ShareInfo(LanZouCloud.NETWORK_ERROR) + name = file_info.json()['text'] # 无后缀的文件名(获得后缀又要发送请求,没有就没有吧,尽可能减少请求数量) + desc = file_info.json()['info'] + else: + url = f_info['new_url'] # 文件夹的分享链接可以直接拿到 + name = f_info['name'] # 文件夹名 + desc = f_info['des'] # 文件夹描述 + return ShareInfo(LanZouCloud.SUCCESS, name=name, url=url, desc=desc, pwd=pwd) + + def set_passwd(self, fid, passwd='', is_file=True) -> int: + """设置网盘文件(夹)的提取码""" + # id 无效或者 id 类型不对应仍然返回成功 :( + # 文件夹提取码长度 0-12 位 文件提取码 2-6 位 + passwd_status = 0 if passwd == '' else 1 # 是否开启密码 + if is_file: + post_data = {"task": 23, "file_id": fid, "shows": passwd_status, "shownames": passwd} + else: + post_data = {"task": 16, "folder_id": fid, "shows": passwd_status, "shownames": passwd} + result = self._post(self._doupload_url, post_data) + if not result: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED + + def mkdir(self, parent_id, folder_name, desc='') -> int: + """创建文件夹(同时设置描述)""" + folder_name = folder_name.replace(' ', '_') # 文件夹名称不能包含空格 + folder_name = name_format(folder_name) # 去除非法字符 + folder_list = self.get_dir_list(parent_id) + if folder_list.find_by_name(folder_name): # 如果文件夹已经存在,直接返回 id + return folder_list.find_by_name(folder_name).id + raw_folders = self.get_move_folders() + post_data = {"task": 2, "parent_id": parent_id or -1, "folder_name": folder_name, + "folder_description": desc} + result = self._post(self._doupload_url, post_data) # 创建文件夹 + if not result or result.json()['zt'] != 1: + logger.debug(f"Mkdir {folder_name} error, {parent_id=}") + return LanZouCloud.MKDIR_ERROR # 正常时返回 id 也是 int,为了方便判断是否成功,网络异常或者创建失败都返回相同错误码 + # 允许再不同路径创建同名文件夹, 移动时可通过 get_move_paths() 区分 + for folder in self.get_move_folders(): + if not raw_folders.find_by_id(folder.id): + logger.debug(f"Mkdir {folder_name} #{folder.id} in {parent_id=}") + return folder.id + logger.debug(f"Mkdir {folder_name} error, {parent_id=}") + return LanZouCloud.MKDIR_ERROR + + def _set_dir_info(self, folder_id, folder_name, desc='') -> int: + """重命名文件夹及其描述""" + # 不能用于重命名文件,id 无效仍然返回成功 + folder_name = name_format(folder_name) + post_data = {'task': 4, 'folder_id': folder_id, 'folder_name': folder_name, 'folder_description': desc} + result = self._post(self._doupload_url, post_data) + if not result: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED + + def rename_dir(self, folder_id, folder_name) -> int: + """重命名文件夹""" + # 重命名文件要开会员额 + info = self.get_share_info(folder_id, is_file=False) + if info.code != LanZouCloud.SUCCESS: + return info.code + return self._set_dir_info(folder_id, folder_name, info.desc) + + def set_desc(self, fid, desc, is_file=True) -> int: + """设置文件(夹)描述""" + if is_file: + # 文件描述一旦设置了值,就不能再设置为空 + post_data = {'task': 11, 'file_id': fid, 'desc': desc} + result = self._post(self._doupload_url, post_data) + if not result: + return LanZouCloud.NETWORK_ERROR + elif result.json()['zt'] != 1: + return LanZouCloud.FAILED + return LanZouCloud.SUCCESS + else: + # 文件夹描述可以置空 + info = self.get_share_info(fid, is_file=False) + if info.code != LanZouCloud.SUCCESS: + return info.code + return self._set_dir_info(fid, info.name, desc) + + def rename_file(self, file_id, filename): + """允许会员重命名文件(无法修后缀名)""" + post_data = {'task': 46, 'file_id': file_id, 'file_name': name_format(filename), 'type': 2} + result = self._post(self._doupload_url, post_data) + if not result: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED + + def get_move_folders(self) -> FolderList: + """获取全部文件夹 id-name 列表,用于移动文件至新的文件夹""" + # 这里 file_id 可以为任意值,不会对结果产生影响 + result = FolderList() + result.append(FolderId(name='LanZouCloud', id=-1)) + resp = self._post(self._doupload_url, data={"task": 19, "file_id": -1}) + if not resp or resp.json()['zt'] != 1: # 获取失败或者网络异常 + return result + info = resp.json()['info'] or [] # 新注册用户无数据, info=None + for folder in info: + folder_id, folder_name = int(folder['folder_id']), folder['folder_name'] + result.append(FolderId(folder_name, folder_id)) + return result + + def get_move_paths(self) -> List[FolderList]: + """获取所有文件夹的绝对路径(耗时长)""" + # 官方 bug, 可能会返回一些已经被删除的"幽灵文件夹" + result = [] + root = FolderList() + root.append(FolderId('LanZouCloud', -1)) + result.append(root) + resp = self._post(self._doupload_url, data={"task": 19, "file_id": -1}) + if not resp or resp.json()['zt'] != 1: # 获取失败或者网络异常 + return result + + ex = ThreadPoolExecutor() # 线程数 min(32, os.cpu_count() + 4) + id_list = [int(folder['folder_id']) for folder in resp.json()['info']] + task_list = [ex.submit(self.get_full_path, fid) for fid in id_list] + for task in as_completed(task_list): + result.append(task.result()) + return sorted(result) + + def move_file(self, file_id, folder_id=-1) -> int: + """移动文件到指定文件夹""" + # 移动回收站文件也返回成功(实际上行不通) (+_+)? + post_data = {'task': 20, 'file_id': file_id, 'folder_id': folder_id} + result = self._post(self._doupload_url, post_data) + logger.debug(f"Move file {file_id=} to {folder_id=}") + if not result: + return LanZouCloud.NETWORK_ERROR + return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED + + def move_folder(self, folder_id, parent_folder_id=-1) -> int: + """移动文件夹(官方并没有直接支持此功能)""" + if folder_id == parent_folder_id or parent_folder_id < -1: + return LanZouCloud.FAILED # 禁止移动文件夹到自身,禁止移动到 -2 这样的文件夹(文件还在,但是从此不可见) + + folder = self.get_move_folders().find_by_id(folder_id) + if not folder: + logger.debug(f"Not found folder :{folder_id=}") + return LanZouCloud.FAILED + + if self.get_dir_list(folder_id): + logger.debug(f"Found subdirectory in {folder=}") + return LanZouCloud.FAILED # 递归操作可能会产生大量请求,这里只移动单层文件夹 + + info = self.get_share_info(folder_id, False) + new_folder_id = self.mkdir(parent_folder_id, folder.name, info.desc) # 在目标文件夹下创建同名文件夹 + + if new_folder_id == LanZouCloud.MKDIR_ERROR: + return LanZouCloud.FAILED + elif new_folder_id == folder_id: # 移动文件夹到同一目录 + return LanZouCloud.FAILED + + self.set_passwd(new_folder_id, info.pwd, False) # 保持密码相同 + ex = ThreadPoolExecutor() + task_list = [ex.submit(self.move_file, file.id, new_folder_id) for file in self.get_file_list(folder_id)] + for task in as_completed(task_list): + if task.result() != LanZouCloud.SUCCESS: + return LanZouCloud.FAILED + self.delete(folder_id, False) # 全部移动完成后删除原文件夹 + self.delete_rec(folder_id, False) + return LanZouCloud.SUCCESS + + def _upload_small_file(self, file_path, folder_id=-1, *, callback=None, uploaded_handler=None) -> int: + """绕过格式限制上传不超过 max_size 的文件""" + if not os.path.isfile(file_path): + return LanZouCloud.PATH_ERROR + + need_delete = False # 上传完成是否删除 + if not is_name_valid(os.path.basename(file_path)): # 不允许上传的格式 + if self._limit_mode: # 不允许绕过官方限制 + return LanZouCloud.OFFICIAL_LIMITED + file_path = let_me_upload(file_path) # 添加了报尾的新文件 + need_delete = True + + # 文件已经存在同名文件就删除 + filename = name_format(os.path.basename(file_path)) + file_list = self.get_file_list(folder_id) + if file_list.find_by_name(filename): + self.delete(file_list.find_by_name(filename).id) + logger.debug(f'Upload {file_path=} to {folder_id=}') + + file = open(file_path, 'rb') + post_data = { + "task": "1", + "folder_id": str(folder_id), + "id": "WU_FILE_0", + "name": filename, + "upload_file": (filename, file, 'application/octet-stream') + } + + post_data = MultipartEncoder(post_data) + tmp_header = self._headers.copy() + tmp_header['Content-Type'] = post_data.content_type + + # MultipartEncoderMonitor 每上传 8129 bytes数据调用一次回调函数,问题根源是 httplib 库 + # issue : https://github.com/requests/toolbelt/issues/75 + # 上传完成后,回调函数会被错误的多调用一次(强迫症受不了)。因此,下面重新封装了回调函数,修改了接受的参数,并阻断了多余的一次调用 + self._upload_finished_flag = False # 上传完成的标志 + + def _call_back(read_monitor): + if callback is not None: + if not self._upload_finished_flag: + callback(filename, read_monitor.len, read_monitor.bytes_read) + if read_monitor.len == read_monitor.bytes_read: + self._upload_finished_flag = True + + monitor = MultipartEncoderMonitor(post_data, _call_back) + result = self._post('https://pc.woozooo.com/fileup.php', data=monitor, headers=tmp_header, timeout=3600) + if not result: # 网络异常 + return LanZouCloud.NETWORK_ERROR + else: + result = result.json() + if result["zt"] != 1: + logger.debug(f'Upload failed: {result=}') + return LanZouCloud.FAILED # 上传失败 + + if uploaded_handler is not None: + file_id = int(result["text"][0]["id"]) + uploaded_handler(file_id, is_file=True) # 对已经上传的文件再进一步处理 + + if need_delete: + file.close() + os.remove(file_path) + return LanZouCloud.SUCCESS + + def _upload_big_file(self, file_path, dir_id, *, callback=None, uploaded_handler=None): + """上传大文件, 且使得回调函数只显示一个文件""" + if self._limit_mode: # 不允许绕过官方限制 + return LanZouCloud.OFFICIAL_LIMITED + + file_size = os.path.getsize(file_path) # 原始文件的字节大小 + file_name = os.path.basename(file_path) + tmp_dir = os.path.dirname(file_path) + os.sep + '__' + '.'.join(file_name.split('.')[:-1]) # 临时文件保存路径 + record_file = tmp_dir + os.sep + file_name + '.record' # 记录文件,大文件没有完全上传前保留,用于支持续传 + uploaded_size = 0 # 记录已上传字节数,用于回调函数 + + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + if not os.path.exists(record_file): # 初始化记录文件 + info = {'name': file_name, 'size': file_size, 'uploaded': 0, 'parts': []} + with open(record_file, 'wb') as f: + pickle.dump(info, f) + else: + with open(record_file, 'rb') as f: + info = pickle.load(f) + uploaded_size = info['uploaded'] # 读取已经上传的大小 + logger.debug(f"Find upload record: {uploaded_size}/{file_size}") + + def _callback(name, t_size, now_size): # 重新封装回调函数,隐藏数据块上传细节 + nonlocal uploaded_size + if callback is not None: + # MultipartEncoder 以后,文件数据流比原文件略大几百字节, now_size 略大于 file_size + now_size = uploaded_size + now_size + now_size = now_size if now_size < file_size else file_size # 99.99% -> 100.00% + callback(file_name, file_size, now_size) + + def _close_pwd(fid, is_file): # 数据块上传后默认关闭提取码 + self.set_passwd(fid) + + while uploaded_size < file_size: + data_size, data_path = big_file_split(file_path, self._max_size, start_byte=uploaded_size) + code = self._upload_small_file(data_path, dir_id, callback=_callback, uploaded_handler=_close_pwd) + if code == LanZouCloud.SUCCESS: + uploaded_size += data_size # 更新已上传的总字节大小 + info['uploaded'] = uploaded_size + info['parts'].append(os.path.basename(data_path)) # 记录已上传的文件名 + with open(record_file, 'wb') as f: + logger.debug(f"Update record file: {uploaded_size}/{file_size}") + pickle.dump(info, f) + else: + logger.debug(f"Upload data file failed: {data_path=}") + return LanZouCloud.FAILED + os.remove(data_path) # 删除临时数据块 + min_s, max_s = self._upload_delay # 设置两次上传间的延时,减小封号可能性 + sleep_time = uniform(min_s, max_s) + logger.debug(f"Sleeping, Upload task will resume after {sleep_time:.2f}s...") + sleep(sleep_time) + + # 全部数据块上传完成 + record_name = list(file_name.replace('.', '')) # 记录文件名也打乱 + shuffle(record_name) + record_name = name_format(''.join(record_name)) + '.txt' + record_file_new = tmp_dir + os.sep + record_name + os.rename(record_file, record_file_new) + code = self._upload_small_file(record_file_new, dir_id, uploaded_handler=_close_pwd) # 上传记录文件 + if code != LanZouCloud.SUCCESS: + logger.debug(f"Upload record file failed: {record_file_new}") + return LanZouCloud.FAILED + # 记录文件上传成功,删除临时文件 + shutil.rmtree(tmp_dir) + logger.debug(f"Upload finished, Delete tmp folder:{tmp_dir}") + return LanZouCloud.SUCCESS + + def upload_file(self, file_path, folder_id=-1, *, callback=None, uploaded_handler=None) -> int: + """解除限制上传文件 + :param callback 用于显示上传进度的回调函数 + def callback(file_name, total_size, now_size): + print(f"\r文件名:{file_name}, 进度: {now_size}/{total_size}") + ... + + :param uploaded_handler 用于进一步处理上传完成后的文件, 对大文件而已是处理文件夹(数据块默认关闭密码) + def uploaded_handler(fid, is_file): + if is_file: + self.set_desc(fid, '...', is_file=True) + ... + """ + if not os.path.isfile(file_path): + return LanZouCloud.PATH_ERROR + + # 单个文件不超过 max_size 直接上传 + if os.path.getsize(file_path) <= self._max_size * 1048576: + return self._upload_small_file(file_path, folder_id, callback=callback, uploaded_handler=uploaded_handler) + + # 上传超过 max_size 的文件 + if self._limit_mode: + return LanZouCloud.OFFICIAL_LIMITED + + folder_name = os.path.basename(file_path) # 保存分段文件的文件夹名 + dir_id = self.mkdir(folder_id, folder_name, 'Big File') + if dir_id == LanZouCloud.MKDIR_ERROR: + return LanZouCloud.MKDIR_ERROR # 创建文件夹失败就退出 + + if uploaded_handler is not None: + uploaded_handler(dir_id, is_file=False) + return self._upload_big_file(file_path, dir_id, callback=callback, uploaded_handler=uploaded_handler) + + def upload_dir(self, dir_path, folder_id=-1, *, callback=None, failed_callback=None, uploaded_handler=None): + """批量上传文件夹中的文件(不会递归上传子文件夹) + :param folder_id: 网盘文件夹 id + :param dir_path: 文件夹路径 + :param callback 用于显示进度 + def callback(file_name, total_size, now_size): + print(f"\r文件名:{file_name}, 进度: {now_size}/{total_size}") + ... + :param failed_callback 用于处理上传失败文件的回调函数 + def failed_callback(code, file_name): + print(f"上传失败, 文件名: {file_name}, 错误码: {code}") + ... + :param uploaded_handler 用于进一步处理上传完成后的文件, 对大文件而已是处理文件夹(数据块默认关闭密码) + def uploaded_handler(fid, is_file): + if is_file: + self.set_desc(fid, '...', is_file=True) + ... + """ + if not os.path.isdir(dir_path): + return LanZouCloud.PATH_ERROR + + dir_name = dir_path.split(os.sep)[-1] + dir_id = self.mkdir(folder_id, dir_name, '批量上传') + if dir_id == LanZouCloud.MKDIR_ERROR: + return LanZouCloud.MKDIR_ERROR + + for filename in os.listdir(dir_path): + file_path = dir_path + os.sep + filename + if not os.path.isfile(file_path): + continue # 跳过子文件夹 + code = self.upload_file(file_path, dir_id, callback=callback, uploaded_handler=uploaded_handler) + if code != LanZouCloud.SUCCESS: + if failed_callback is not None: + failed_callback(code, filename) + return LanZouCloud.SUCCESS + + def down_file_by_url(self, share_url, pwd='', save_path='./Download', callback=None) -> int: + """通过分享链接下载文件(需提取码)""" + if not is_file_url(share_url): + return LanZouCloud.URL_INVALID + if not os.path.exists(save_path): + os.makedirs(save_path) + + info = self.get_durl_by_url(share_url, pwd) + logger.debug(f'File direct url info: {info}') + if info.code != LanZouCloud.SUCCESS: + return info.code + + resp = self._get(info.durl, stream=True) + if not resp: + return LanZouCloud.FAILED + total_size = int(resp.headers['Content-Length']) + + file_path = save_path + os.sep + info.name + logger.debug(f'Save file to {file_path=}') + if os.path.exists(file_path): + now_size = os.path.getsize(file_path) # 本地已经下载的文件大小 + else: + now_size = 0 + chunk_size = 4096 + last_512_bytes = b'' # 用于识别文件是否携带真实文件名信息 + headers = {**self._headers, 'Range': 'bytes=%d-' % now_size} + resp = self._get(info.durl, stream=True, headers=headers) + + if resp is None: # 网络异常 + return LanZouCloud.FAILED + if resp.status_code == 416: # 已经下载完成 + return LanZouCloud.SUCCESS + + with open(file_path, "ab") as f: + for chunk in resp.iter_content(chunk_size): + if chunk: + f.write(chunk) + f.flush() + now_size += len(chunk) + if total_size - now_size < 512: + last_512_bytes += chunk + if callback is not None: + callback(info.name, total_size, now_size) + # 尝试解析文件报尾 + file_info = un_serialize(last_512_bytes[-512:]) + if file_info is not None and 'padding' in file_info: # 大文件的记录文件也可以反序列化出 name,但是没有 padding + real_name = file_info['name'] + new_file_path = save_path + os.sep + real_name + logger.debug(f"Find meta info: {real_name=}") + if os.path.exists(new_file_path): + os.remove(new_file_path) # 存在同名文件则删除 + os.rename(file_path, new_file_path) + with open(new_file_path, 'rb+') as f: + f.seek(-512, 2) # 截断最后 512 字节数据 + f.truncate() + return LanZouCloud.SUCCESS + + def down_file_by_id(self, fid, save_path='./Download', callback=None) -> int: + """登录用户通过id下载文件(无需提取码)""" + info = self.get_share_info(fid, is_file=True) + if info.code != LanZouCloud.SUCCESS: + return info.code + return self.down_file_by_url(info.url, info.pwd, save_path, callback) + + def get_folder_info_by_url(self, share_url, dir_pwd='') -> FolderDetail: + """获取文件夹里所有文件的信息""" + if is_file_url(share_url): + return FolderDetail(LanZouCloud.URL_INVALID) + try: + html = requests.get(share_url, headers=self._headers).text + except requests.RequestException: + return FolderDetail(LanZouCloud.NETWORK_ERROR) + if '文件不存在' in html: + return FolderDetail(LanZouCloud.FILE_CANCELLED) + if '请输入密码' in html and len(dir_pwd) == 0: + return FolderDetail(LanZouCloud.LACK_PASSWORD) + try: + # 获取文件需要的参数 + html = remove_notes(html) + lx = re.findall(r"'lx':'?(\d)'?,", html)[0] + t = re.findall(r"var [0-9a-z]{6} = '(\d{10})';", html)[0] + k = re.findall(r"var [0-9a-z]{6} = '([0-9a-z]{15,})';", html)[0] + # 文件夹的信息 + folder_id = re.findall(r"'fid':'?(\d+)'?,", html)[0] + folder_name = re.findall(r"var.+?='(.+?)';\n.+document.title", html)[0] + folder_time = re.findall(r'class="rets">([\d\-]+?)(.+?)', html) # 无描述时无法完成匹配 + folder_desc = folder_desc[0] if len(folder_desc) == 1 else '' + except IndexError: + return FolderDetail(LanZouCloud.FAILED) + + page = 1 + files = FileList() + while True: + try: + post_data = {'lx': lx, 'pg': page, 'k': k, 't': t, 'fid': folder_id, 'pwd': dir_pwd} + resp = self._post(self._host_url + '/filemoreajax.php', data=post_data, headers=self._headers).json() + except requests.RequestException: + return FolderDetail(LanZouCloud.NETWORK_ERROR) + if resp['zt'] == 1: # 成功获取一页文件信息 + for f in resp["text"]: + files.append(FileInFolder( + name=f["name_all"], # 文件名 + time=time_format(f["time"]), # 上传时间 + size=f["size"], # 文件大小 + type=f["name_all"].split('.')[-1], # 文件格式 + url=self._host_url + "/" + f["id"] # 文件分享链接 + )) + page += 1 # 下一页 + continue + elif resp['zt'] == 2: # 已经拿到全部的文件信息 + break + elif resp['zt'] == 3: # 提取码错误 + return FolderDetail(LanZouCloud.PASSWORD_ERROR) + elif resp["zt"] == 4: + continue + else: + return FolderDetail(LanZouCloud.FAILED) # 其它未知错误 + # 通过文件的时间信息补全文件夹的年份(如果有文件的话) + if files: # 最后一个文件上传时间最早,文件夹的创建年份与其相同 + folder_time = files[-1].time.split('-')[0] + '-' + folder_time + else: # 可恶,没有文件,日期就设置为今年吧 + folder_time = datetime.today().strftime('%Y-%m-%d') + return FolderDetail(LanZouCloud.SUCCESS, + FolderInfo(folder_name, folder_id, dir_pwd, folder_time, folder_desc, share_url), + files) + + def get_folder_info_by_id(self, folder_id): + """通过 id 获取文件夹及内部文件信息""" + info = self.get_share_info(folder_id, is_file=False) + if info.code != LanZouCloud.SUCCESS: + return FolderDetail(info.code) + return self.get_folder_info_by_url(info.url, info.pwd) + + def _check_big_file(self, file_list): + """检查文件列表,判断是否为大文件分段数据""" + txt_files = file_list.filter(lambda f: f.name.endswith('.txt') and 'M' not in f.size) + if txt_files and len(txt_files) == 1: # 文件夹里有且仅有一个 txt, 很有可能是保存大文件的文件夹 + try: + info = self.get_durl_by_url(txt_files[0].url) + except AttributeError: + info = self.get_durl_by_id(txt_files[0].id) + if info.code != LanZouCloud.SUCCESS: + logger.debug(f"Big file checking: Failed") + return None + resp = self._get(info.durl) + info = un_serialize(resp.content) if resp else None + if info is not None: # 确认是大文件 + name, size, *_, parts = info.values() # 真实文件名, 文件字节大小, (其它数据),分段数据文件名(有序) + file_list = [file_list.find_by_name(p) for p in parts] + if all(file_list): # 分段数据完整 + logger.debug(f"Big file checking: PASS , {name=}, {size=}") + return name, size, file_list + logger.debug(f"Big file checking: Failed, Missing some data") + logger.debug(f"Big file checking: Failed") + return None + + def _down_big_file(self, name, total_size, file_list, save_path, *, callback=None): + """下载分段数据到一个文件,回调函数只显示一个文件 + 支持大文件下载续传,下载完成后重复下载不会执行覆盖操作,直接返回状态码 SUCCESS + """ + big_file = save_path + os.sep + name + record_file = big_file + '.record' + + if not os.path.exists(save_path): + os.makedirs(save_path) + + if not os.path.exists(record_file): # 初始化记录文件 + info = {'last_ending': 0, 'finished': []} # 记录上一个数据块结尾地址和已经下载的数据块 + with open(record_file, 'wb') as rf: + pickle.dump(info, rf) + else: # 读取记录文件,下载续传 + with open(record_file, 'rb') as rf: + info = pickle.load(rf) + file_list = [f for f in file_list if f.name not in info['finished']] # 排除已下载的数据块 + logger.debug(f"Find download record file: {info}") + + with open(big_file, 'ab') as bf: + for file in file_list: + try: + durl_info = self.get_durl_by_url(file.url) # 分段文件无密码 + except AttributeError: + durl_info = self.get_durl_by_id(file.id) + if durl_info.code != LanZouCloud.SUCCESS: + logger.debug(f"Can't get direct url: {file}") + return durl_info.code + # 准备向大文件写入数据 + file_size_now = os.path.getsize(big_file) + down_start_byte = file_size_now - info['last_ending'] # 当前数据块上次下载中断的位置 + headers = {**self._headers, 'Range': 'bytes=%d-' % down_start_byte} + logger.debug(f"Download {file.name}, Range: {down_start_byte}-") + resp = self._get(durl_info.durl, stream=True, headers=headers) + + if resp is None: # 网络错误, 没有响应数据 + return LanZouCloud.FAILED + if resp.status_code == 416: # 下载完成后重复下载导致 Range 越界, 服务器返回 416 + logger.debug(f"File {name} has already downloaded.") + os.remove(record_file) # 删除记录文件 + return LanZouCloud.SUCCESS + + for chunk in resp.iter_content(4096): + if chunk: + file_size_now += len(chunk) + bf.write(chunk) + bf.flush() # 确保缓冲区立即写入文件,否则下一次写入时获取的文件大小会有偏差 + if callback: + callback(name, total_size, file_size_now) + + # 一块数据写入完成,更新记录文件 + info['finished'].append(file.name) + info['last_ending'] = file_size_now + with open(record_file, 'wb') as rf: + pickle.dump(info, rf) + logger.debug(f"Update download record info: {info}") + # 全部数据块下载完成, 记录文件可以删除 + logger.debug(f"Delete download record file: {record_file}") + os.remove(record_file) + return LanZouCloud.SUCCESS + + def down_dir_by_url(self, share_url, dir_pwd='', save_path='./Download', *, callback=None, mkdir=True, + failed_callback=None) -> int: + """通过分享链接下载文件夹 + :param save_path 文件夹保存路径 + :param mkdir 是否在 save_path 下创建与远程文件夹同名的文件夹 + :param callback: 用于显示单个文件下载进度的回调函数 + :param failed_callback 用于处理下载失败文件的回调函数, + def failed_callback(code, file): + print(f"文件名: {file.name}, 时间: {file.time}, 大小: {file.size}, 类型: {file.type}") # 共有属性 + if hasattr(file, 'url'): # 使用 URL 下载时 + print(f"文件下载失败, 链接: {file.url}, 错误码: code") + else: # 登录后使用 ID 下载时 + print(f"文件下载失败, ID: {file.id}, 错误码: code") + """ + folder_detail = self.get_folder_info_by_url(share_url, dir_pwd) + if folder_detail.code != LanZouCloud.SUCCESS: # 获取文件信息失败 + return folder_detail.code + + # 检查是否大文件分段数据 + info = self._check_big_file(folder_detail.files) + if info is not None: + return self._down_big_file(*info, save_path, callback=callback) + + if mkdir: # 自动创建子文件夹 + save_path = save_path + os.sep + folder_detail.folder.name + if not os.path.exists(save_path): + os.makedirs(save_path) + + # 不是大文件分段数据,直接下载 + for file in folder_detail.files: + code = self.down_file_by_url(file.url, dir_pwd, save_path, callback) + logger.debug(f'Download file result: Code:{code}, File: {file}') + if code != LanZouCloud.SUCCESS: + if failed_callback is not None: + failed_callback(code, file) + + return LanZouCloud.SUCCESS + + def down_dir_by_id(self, folder_id, save_path='./Download', *, callback=None, mkdir=True, + failed_callback=None) -> int: + """登录用户通过id下载文件夹""" + file_list = self.get_file_list(folder_id) + if len(file_list) == 0: + return LanZouCloud.FAILED + + # 检查是否大文件分段数据 + info = self._check_big_file(file_list) + if info is not None: + return self._down_big_file(*info, save_path, callback=callback) + + if mkdir: # 自动创建子目录 + share_info = self.get_share_info(folder_id, False) + if share_info.code != LanZouCloud.SUCCESS: + return share_info.code + save_path = save_path + os.sep + share_info.name + if not os.path.exists(save_path): + logger.debug(f"Mkdir {save_path}") + os.makedirs(save_path) + + for file in file_list: + code = self.down_file_by_id(file.id, save_path, callback) + logger.debug(f'Download file result: Code:{code}, File: {file}') + if code != LanZouCloud.SUCCESS: + if failed_callback is not None: + failed_callback(code, file) + + return LanZouCloud.SUCCESS diff --git a/lanzou/api/models.py b/lanzou/api/models.py new file mode 100644 index 0000000..fb95988 --- /dev/null +++ b/lanzou/api/models.py @@ -0,0 +1,100 @@ +""" +容器类,用于储存文件、文件夹,支持 list 的操作,同时支持许多方法方便操作元素 +元素类型为 namedtuple,至少拥有 name id 两个属性才能放入容器 +""" + +__all__ = ['FileList', 'FolderList'] + + +class ItemList: + """具有 name, id 属性对象的列表""" + + def __init__(self): + self._items = [] + + def __len__(self): + return len(self._items) + + def __getitem__(self, index): + return self._items[index] + + def __iter__(self): + return iter(self._items) + + def __repr__(self): + return f"" + + def __lt__(self, other): + """用于路径 List 之间排序""" + return '/'.join(i.name for i in self) < '/'.join(i.name for i in other) + + @property + def name_id(self): + """所有 item 的 name-id 列表,兼容旧版""" + return {it.name: it.id for it in self} + + @property + def all_name(self): + """所有 item 的 name 列表""" + return [it.name for it in self] + + def append(self, item): + """在末尾插入元素""" + self._items.append(item) + + def index(self, item): + """获取索引""" + return self._items.index(item) + + def insert(self, pos, item): + """指定位置插入元素""" + self._items.insert(pos, item) + + def clear(self): + """清空元素""" + self._items.clear() + + def filter(self, condition) -> list: + """筛选出满足条件的 item + condition(item) -> True + """ + return [it for it in self if condition(it)] + + def find_by_name(self, name: str): + """使用文件名搜索(仅返回首个匹配项)""" + for item in self: + if name == item.name: + return item + return None + + def find_by_id(self, fid: int): + """使用 id 搜索(精确)""" + for item in self: + if fid == item.id: + return item + return None + + def pop_by_id(self, fid): + for item in self: + if item.id == fid: + self._items.remove(item) + return item + return None + + def update_by_id(self, fid, **kwargs): + """通过 id 搜索元素并更新""" + item = self.find_by_id(fid) + pos = self.index(item) + data = item._asdict() + data.update(kwargs) + self._items[pos] = item.__class__(**data) + + +class FileList(ItemList): + """文件列表类""" + pass + + +class FolderList(ItemList): + """文件夹列表类""" + pass diff --git a/lanzou/api/types.py b/lanzou/api/types.py new file mode 100644 index 0000000..b232356 --- /dev/null +++ b/lanzou/api/types.py @@ -0,0 +1,18 @@ +""" +API 处理后返回的数据类型 +""" + +from collections import namedtuple + +File = namedtuple('File', ['name', 'id', 'time', 'size', 'type', 'downs', 'has_pwd', 'has_des']) +Folder = namedtuple('Folder', ['name', 'id', 'has_pwd', 'desc']) +FolderId = namedtuple('FolderId', ['name', 'id']) +RecFile = namedtuple('RecFile', ['name', 'id', 'type', 'size', 'time']) +RecFolder = namedtuple('RecFolder', ['name', 'id', 'size', 'time', 'files']) +FileDetail = namedtuple('FileDetail', ['code', 'name', 'size', 'type', 'time', 'desc', 'pwd', 'url', 'durl'], + defaults=(0, *[''] * 8)) +ShareInfo = namedtuple('ShareInfo', ['code', 'name', 'url', 'pwd', 'desc'], defaults=(0, *[''] * 4)) +DirectUrlInfo = namedtuple('DirectUrlInfo', ['code', 'name', 'durl']) +FolderInfo = namedtuple('Folder', ['name', 'id', 'pwd', 'time', 'desc', 'url'], defaults=('',) * 6) +FileInFolder = namedtuple('FileInFolder', ['name', 'time', 'size', 'type', 'url'], defaults=('',) * 5) +FolderDetail = namedtuple('FolderDetail', ['code', 'folder', 'files'], defaults=(0, None, None)) diff --git a/lanzou/api/utils.py b/lanzou/api/utils.py new file mode 100644 index 0000000..b9044b5 --- /dev/null +++ b/lanzou/api/utils.py @@ -0,0 +1,192 @@ +""" +API 处理网页数据、数据切片时使用的工具 +""" + +import logging +import os +import pickle +import re +from datetime import timedelta, datetime +from random import uniform, choices, sample, shuffle, choice +import requests + +__all__ = ['logger', 'remove_notes', 'name_format', 'time_format', 'is_name_valid', 'is_file_url', + 'is_folder_url', 'big_file_split', 'un_serialize', 'let_me_upload'] + +# 调试日志设置 +logger = logging.getLogger('lanzou') +logger.setLevel(logging.ERROR) +formatter = logging.Formatter( + fmt="%(asctime)s [line:%(lineno)d] %(funcName)s %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S") +console = logging.StreamHandler() +console.setFormatter(formatter) +logger.addHandler(console) + +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', + 'Referer': 'https://www.lanzous.com', + 'Accept-Language': 'zh-CN,zh;q=0.9', +} + + +def remove_notes(html: str) -> str: + """删除网页的注释""" + # 去掉 html 里面的 // 和 注释,防止干扰正则匹配提取数据 + # 蓝奏云的前端程序员喜欢改完代码就把原来的代码注释掉,就直接推到生产环境了 =_= + html = re.sub(r'|\s+//\s*.+', '', html) # html 注释 + html = re.sub(r'(.+?[,;])\s*//.+', r'\1', html) # js 注释 + return html + + +def name_format(name: str) -> str: + """去除非法字符""" + name = name.replace(u'\xa0', ' ').replace(u'\u3000', ' ').replace(' ', ' ') # 去除其它字符集的空白符,去除重复空白字符 + return re.sub(r'[$%^!*<>)(+=`\'\"/:;,?]', '', name) + + +def time_format(time_str: str) -> str: + """输出格式化时间 %Y-%m-%d""" + if '秒前' in time_str or '分钟前' in time_str or '小时前' in time_str: + return datetime.today().strftime('%Y-%m-%d') + elif '昨天' in time_str: + return (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d') + elif '前天' in time_str: + return (datetime.today() - timedelta(days=2)).strftime('%Y-%m-%d') + elif '天前' in time_str: + days = time_str.replace(' 天前', '') + return (datetime.today() - timedelta(days=int(days))).strftime('%Y-%m-%d') + else: + return time_str + + +def is_name_valid(filename: str) -> bool: + """检查文件名是否允许上传""" + + valid_suffix_list = ('ppt', 'xapk', 'ke', 'azw', 'cpk', 'gho', 'dwg', 'db', 'docx', 'deb', 'e', 'ttf', 'xls', 'bat', + 'crx', 'rpm', 'txf', 'pdf', 'apk', 'ipa', 'txt', 'mobi', 'osk', 'dmg', 'rp', 'osz', 'jar', + 'ttc', 'z', 'w3x', 'xlsx', 'cetrainer', 'ct', 'rar', 'mp3', 'pptx', 'mobileconfig', 'epub', + 'imazingapp', 'doc', 'iso', 'img', 'appimage', '7z', 'rplib', 'lolgezi', 'exe', 'azw3', 'zip', + 'conf', 'tar', 'dll', 'flac', 'xpa', 'lua') + + return filename.split('.')[-1] in valid_suffix_list + + +def is_file_url(share_url: str) -> bool: + """判断是否为文件的分享链接""" + base_pat = r'https?://.+?\.lanzous.com/.+' + user_pat = r'https?://.+?\.lanzous.com/i[a-z0-9]{5,}/?' # 普通用户 URL 规则 + if not re.fullmatch(base_pat, share_url): + return False + elif re.fullmatch(user_pat, share_url): + return True + else: # VIP 用户的 URL 很随意 + try: + html = requests.get(share_url, headers=headers).text + html = remove_notes(html) + return True if re.search(r'class="fileinfo"|id="file"|文件描述', html) else False + except (requests.RequestException, Exception): + return False + + +def is_folder_url(share_url: str) -> bool: + """判断是否为文件夹的分享链接""" + base_pat = r'https?://.+?\.lanzous.com/.+' + user_pat = r'https?://.+?\.lanzous.com/b[a-z0-9]{7,}/?' + if not re.fullmatch(base_pat, share_url): + return False + elif re.fullmatch(user_pat, share_url): + return True + else: # VIP 用户的 URL 很随意 + try: + html = requests.get(share_url, headers=headers).text + html = remove_notes(html) + return True if re.search(r'id="infos"', html) else False + except (requests.RequestException, Exception): + return False + + +def un_serialize(data: bytes): + """反序列化文件信息数据""" + try: + ret = pickle.loads(data) + if not isinstance(ret, dict): + return None + return ret + except Exception: # 这里可能会丢奇怪的异常 + return None + + +def big_file_split(file_path: str, max_size: int = 100, start_byte: int = 0) -> (int, str): + """将大文件拆分为大小、格式随机的数据块, 可指定文件起始字节位置(用于续传) + :return 数据块文件的大小和绝对路径 + """ + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + tmp_dir = os.path.dirname(file_path) + os.sep + '__' + '.'.join(file_name.split('.')[:-1]) + + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + + def get_random_size() -> int: + """按权重生成一个不超过 max_size 的文件大小""" + reduce_size = choices([uniform(0, 20), uniform(20, 30), uniform(30, 60), uniform(60, 80)], weights=[2, 5, 2, 1]) + return round((max_size - reduce_size[0]) * 1048576) + + def get_random_name() -> str: + """生成一个随机文件名""" + # 这些格式的文件一般都比较大且不容易触发下载检测 + suffix_list = ('zip', 'rar', 'apk', 'ipa', 'exe', 'pdf', '7z', 'tar', 'deb', 'dmg', 'rpm', 'flac') + name = list(file_name.replace('.', '').replace(' ', '')) + name = name + sample('abcdefghijklmnopqrstuvwxyz', 3) + sample('1234567890', 2) + shuffle(name) # 打乱顺序 + name = ''.join(name) + '.' + choice(suffix_list) + return name_format(name) # 确保随机名合法 + + with open(file_path, 'rb') as big_file: + big_file.seek(start_byte) + left_size = file_size - start_byte # 大文件剩余大小 + random_size = get_random_size() + tmp_file_size = random_size if left_size > random_size else left_size + tmp_file_path = tmp_dir + os.sep + get_random_name() + + chunk_size = 524288 # 512KB + left_read_size = tmp_file_size + with open(tmp_file_path, 'wb') as small_file: + while left_read_size > 0: + if left_read_size < chunk_size: # 不足读取一次 + small_file.write(big_file.read(left_read_size)) + break + # 一次读取一块,防止一次性读取占用内存 + small_file.write(big_file.read(chunk_size)) + left_read_size -= chunk_size + + return tmp_file_size, tmp_file_path + + +def let_me_upload(file_path): + """允许文件上传""" + file_size = os.path.getsize(file_path) / 1024 / 1024 # MB + file_name = os.path.basename(file_path) + + big_file_suffix = ['zip', 'rar', 'apk', 'ipa', 'exe', 'pdf', '7z', 'tar', 'deb', 'dmg', 'rpm', 'flac'] + small_file_suffix = big_file_suffix + ['doc', 'epub', 'mobi', 'mp3', 'ppt', 'pptx'] + big_file_suffix = choice(big_file_suffix) + small_file_suffix = choice(small_file_suffix) + suffix = small_file_suffix if file_size < 30 else big_file_suffix + new_file_path = '.'.join(file_path.split('.')[:-1]) + '.' + suffix + + with open(new_file_path, 'wb') as out_f: + # 写入原始文件数据 + with open(file_path, 'rb') as in_f: + chunk = in_f.read(4096) + while chunk: + out_f.write(chunk) + chunk = in_f.read(4096) + # 构建文件 "报尾" 保存真实文件名,大小 512 字节 + # 追加数据到文件尾部,并不会影响文件的使用,无需修改即可分享给其他人使用,自己下载时则会去除,确保数据无误 + padding = 512 - len(file_name.encode('utf-8')) - 42 # 序列化后空字典占 42 字节 + data = {'name': file_name, 'padding': b'\x00' * padding} + data = pickle.dumps(data) + out_f.write(data) + return new_file_path diff --git a/lanzou/cmder/__init__.py b/lanzou/cmder/__init__.py new file mode 100644 index 0000000..c1d0d03 --- /dev/null +++ b/lanzou/cmder/__init__.py @@ -0,0 +1,5 @@ +from lanzou.cmder.config import config + +version = '2.5.0' + +__all__ = ['cmder', 'utils', 'version', 'config'] diff --git a/lanzou/cmder/cmder.py b/lanzou/cmder/cmder.py new file mode 100644 index 0000000..041f537 --- /dev/null +++ b/lanzou/cmder/cmder.py @@ -0,0 +1,455 @@ +from getpass import getpass +from sys import exit as exit_cmd +from webbrowser import open_new_tab + +from lanzou.api.models import FileList, FolderList +from lanzou.api.types import * +from lanzou.cmder import config +from lanzou.cmder.downloader import Downloader, Uploader +from lanzou.cmder.manager import global_task_mgr +from lanzou.cmder.recovery import Recovery +from lanzou.cmder.utils import * + + +class Commander: + """蓝奏网盘命令行""" + + def __init__(self): + self._prompt = '> ' + self._disk = LanZouCloud() + self._task_mgr = global_task_mgr + self._dir_list = FolderList() + self._file_list = FileList() + self._path_list = FolderList() + self._parent_id = -1 + self._parent_name = '' + self._work_name = '' + self._work_id = -1 + self._last_work_id = -1 + self._reader_mode = config.reader_mode + self._default_dir_pwd = config.default_dir_pwd + self._disk.set_max_size(config.max_size) + self._disk.set_upload_delay(config.upload_delay) + self._disk.set_captcha_handler(captcha_handler) + + @staticmethod + def clear(): + clear_screen() + + @staticmethod + def help(): + print_help() + + @staticmethod + def update(): + check_update() + + def bye(self): + if self._task_mgr.has_alive_task(): + info(f"有任务在后台运行, 退出请直接关闭窗口") + else: + exit_cmd(0) + + def rmode(self): + """适用于屏幕阅读器用户的显示方式""" + choice = input("以适宜屏幕阅读器的方式显示(y): ") + if choice and choice.lower() == 'y': + config.reader_mode = True + self._reader_mode = True + info("已启用 Reader Mode") + else: + config.reader_mode = False + self._reader_mode = False + info("已关闭 Reader Mode") + + def cdrec(self): + """进入回收站模式""" + rec = Recovery(self._disk) + rec.run() + self.refresh() + + def xghost(self): + """扫描并删除幽灵文件夹""" + choice = input("需要清理幽灵文件夹吗(y): ") + if choice and choice.lower() == 'y': + self._disk.clean_ghost_folders() + info("清理已完成") + else: + info("清理操作已取消") + + def refresh(self, dir_id=None): + """刷新当前文件夹和路径信息""" + dir_id = self._work_id if dir_id is None else dir_id + self._file_list = self._disk.get_file_list(dir_id) + self._dir_list = self._disk.get_dir_list(dir_id) + self._path_list = self._disk.get_full_path(dir_id) + self._prompt = '/'.join(self._path_list.all_name) + ' > ' + self._last_work_id = self._work_id + self._work_name = self._path_list[-1].name + self._work_id = self._path_list[-1].id + if dir_id != -1: # 如果存在上级路径 + self._parent_name = self._path_list[-2].name + self._parent_id = self._path_list[-2].id + + def login(self): + """使用 cookie 登录""" + if not config.cookie or self._disk.login_by_cookie(config.cookie) != LanZouCloud.SUCCESS: + open_new_tab('https://pc.woozooo.com/') + info("请设置 Cookie 内容:") + ylogin = input("ylogin=") + disk_info = input("phpdisk_info=") + if not ylogin or not disk_info: + error("请输入正确的 Cookie 信息") + return None + cookie = {"ylogin": str(ylogin), "phpdisk_info": disk_info} + if self._disk.login_by_cookie(cookie) == LanZouCloud.SUCCESS: + config.cookie = cookie + else: + error("登录失败,请检查 Cookie 是否正确") + self.refresh() + + def logout(self): + """注销""" + clear_screen() + self._prompt = '> ' + self._disk.logout() + self._file_list.clear() + self._dir_list.clear() + self._path_list = FolderList() + self._parent_id = -1 + self._work_id = -1 + self._last_work_id = -1 + self._parent_name = '' + self._work_name = '' + + config.cookie = None + + def ls(self): + """列出文件(夹)""" + if self._reader_mode: # 方便屏幕阅读器阅读 + for folder in self._dir_list: + pwd_str = '有提取码' if folder.has_pwd else '' + print(f"{folder.name}/ {folder.desc} {pwd_str}") + for file in self._file_list: + pwd_str = '有提取码' if file.has_pwd else '' + print(f"{file.name} 大小:{file.size} 上传时间:{file.time} 下载次数:{file.downs} {pwd_str}") + else: # 普通用户显示方式 + for folder in self._dir_list: + pwd_str = '✦' if folder.has_pwd else '✧' + print("#{0:<12}{1:<4}{2}{3}/".format( + folder.id, pwd_str, text_align(folder.desc, 28), folder.name)) + for file in self._file_list: + pwd_str = '✦' if file.has_pwd else '✧' + print("#{0:<12}{1:<4}{2:<12}{3:>8}{4:>6} {5}".format( + file.id, pwd_str, file.time, file.size, file.downs, file.name)) + + def cd(self, dir_name): + """切换工作目录""" + if not dir_name: + info('cd .. 返回上级路径, cd - 返回上次路径, cd / 返回根目录') + elif dir_name == '..': + self.refresh(self._parent_id) + elif dir_name == '/': + self.refresh(-1) + elif dir_name == '-': + self.refresh(self._last_work_id) + elif dir_name == '.': + pass + elif folder := self._dir_list.find_by_name(dir_name): + self.refresh(folder.id) + else: + error(f'文件夹不存在: {dir_name}') + + def mkdir(self, name): + """创建文件夹""" + if self._dir_list.find_by_name(name): + error(f'文件夹已存在: {name}') + return None + + dir_id = self._disk.mkdir(self._work_id, name, '') + if dir_id == LanZouCloud.MKDIR_ERROR: + error(f'创建文件夹失败(深度最大 4 级)') + return None + # 创建成功,添加到文件夹列表,减少向服务器请求次数 + self._disk.set_passwd(dir_id, self._default_dir_pwd, is_file=False) + self._dir_list.append(Folder(name, dir_id, bool(self._default_dir_pwd), '')) + + def rm(self, name): + """删除文件(夹)""" + if file := self._file_list.find_by_name(name): # 删除文件 + if self._disk.delete(file.id, True) == LanZouCloud.SUCCESS: + self._file_list.pop_by_id(file.id) + else: + error(f'删除文件失败: {name}') + elif folder := self._dir_list.find_by_name(name): # 删除文件夹 + if self._disk.delete(folder.id, False) == LanZouCloud.SUCCESS: + self._dir_list.pop_by_id(folder.id) + else: + error(f'删除文件夹失败(存在子文件夹?): {name}') + else: + error(f'文件(夹)不存在: {name}') + + def rename(self, name): + """重命名文件或文件夹(需要会员)""" + if folder := self._dir_list.find_by_name(name): + fid, is_file = folder.id, False + elif file := self._file_list.find_by_name(name): + fid, is_file = file.id, True + else: + error(f'没有这个文件(夹)的啦: {name}') + return None + + new_name = input(f'重命名 "{name}" 为 ') or '' + if not new_name: + info(f'重命名操作取消') + return None + + if is_file: + if self._disk.rename_file(fid, new_name) != LanZouCloud.SUCCESS: + error('(#°Д°) 文件重命名失败, 请开通会员,文件名不要带后缀') + return None + # 只更新本地索引的文件夹名(调用refresh()要等 1.5s 才能刷新信息) + self._file_list.update_by_id(fid, name=name) + else: + if self._disk.rename_dir(fid, new_name) != LanZouCloud.SUCCESS: + error('文件夹重命名失败') + return None + self._dir_list.update_by_id(fid, name=new_name) + + def mv(self, name): + """移动文件或文件夹""" + if file := self._file_list.find_by_name(name): + fid, is_file = file.id, True + elif folder := self._dir_list.find_by_name(name): + fid, is_file = folder.id, False + else: + error(f"文件(夹)不存在: {name}") + return None + + path_list = self._disk.get_move_paths() + path_list = {'/'.join(path.all_name): path[-1].id for path in path_list} + choice_list = list(path_list.keys()) + + def _condition(typed_str, choice_str): + path_depth = len(choice_str.split('/')) + # 没有输入时, 补全 LanZouCloud,深度 1 + if not typed_str and path_depth == 1: + return True + # LanZouCloud/ 深度为 2,补全同深度的文件夹 LanZouCloud/test 、LanZouCloud/txt + # LanZouCloud/tx 应该补全 LanZouCloud/txt + if path_depth == len(typed_str.split('/')) and choice_str.startswith(typed_str): + return True + + set_completer(choice_list, condition=_condition) + choice = input('请输入路径(TAB键补全) : ') + if not choice or choice not in choice_list: + error(f"目标路径不存在: {choice}") + return None + folder_id = path_list.get(choice) + if is_file: + if self._disk.move_file(fid, folder_id) == LanZouCloud.SUCCESS: + self._file_list.pop_by_id(fid) + else: + error(f"移动文件到 {choice} 失败") + else: + if self._disk.move_folder(fid, folder_id) == LanZouCloud.SUCCESS: + self._dir_list.pop_by_id(fid) + else: + error(f"移动文件夹到 {choice} 失败") + + def down(self, arg): + """自动选择下载方式""" + downloader = Downloader(self._disk) + if arg.startswith('http'): + downloader.set_url(arg) + elif file := self._file_list.find_by_name(arg): # 如果是文件 + path = '/'.join(self._path_list.all_name) + '/' + arg # 文件在网盘的绝对路径 + downloader.set_fid(file.id, is_file=True, f_path=path) + elif folder := self._dir_list.find_by_name(arg): # 如果是文件夹 + path = '/'.join(self._path_list.all_name) + '/' + arg + '/' # 文件夹绝对路径, 加 '/' 以便区分 + downloader.set_fid(folder.id, is_file=False, f_path=path) + else: + error(f'文件(夹)不存在: {arg}') + return None + # 提交下载任务 + self._task_mgr.add_task(downloader) + + def jobs(self, arg): + """显示后台任务列表""" + if arg.isnumeric(): + self._task_mgr.show_detail(int(arg)) + else: + self._task_mgr.show_tasks() + + def upload(self, path): + """上传文件(夹)""" + path = path.strip('\"\' ') # 去除直接拖文件到窗口产生的引号 + if not os.path.exists(path): + error(f'该路径不存在哦: {path}') + return None + uploader = Uploader(self._disk) + if os.path.isfile(path): + uploader.set_upload_path(path, is_file=True) + else: + uploader.set_upload_path(path, is_file=False) + uploader.set_target(self._work_id, self._work_name) + self._task_mgr.add_task(uploader) + + def share(self, name): + """显示分享信息""" + if file := self._file_list.find_by_name(name): # 文件 + inf = self._disk.get_file_info_by_id(file.id) + if inf.code != LanZouCloud.SUCCESS: + error('获取文件信息出错') + return None + + print("-" * 50) + print(f"文件名 : {name}") + print(f"提取码 : {inf.pwd or '无'}") + print(f"文件大小 : {inf.size}") + print(f"上传时间 : {inf.time}") + print(f"分享链接 : {inf.url}") + print(f"描述信息 : {inf.desc or '无'}") + print(f"下载直链 : {inf.durl or '无'}") + print("-" * 50) + + elif folder := self._dir_list.find_by_name(name): # 文件夹 + inf = self._disk.get_folder_info_by_id(folder.id) + if inf.code != LanZouCloud.SUCCESS: + print('ERROR : 获取文件夹信息出错') + return None + + print("-" * 80) + print(f"文件夹名 : {name}") + print(f"提取码 : {inf.folder.pwd or '无'}") + print(f"分享链接 : {inf.folder.url}") + print(f"描述信息 : {inf.folder.desc or '无'}") + print("-" * 80) + + for file in inf.files: + print("+ {0:<12}{1:<9}{2}\t{3}".format(file.time, file.size, file.url, file.name)) + if len(inf.files) != 0: + print("-" * 80) + else: + error(f"文件(夹)不存在: {name}") + + def passwd(self, name): + """设置文件(夹)提取码""" + if file := self._file_list.find_by_name(name): # 文件 + inf = self._disk.get_share_info(file.id, True) + new_pass = input(f'修改提取码 "{inf.pwd or "无"}" -> ') + if 2 <= len(new_pass) <= 6: + if new_pass == 'off': new_pass = '' + if self._disk.set_passwd(file.id, str(new_pass), True) != LanZouCloud.SUCCESS: + error('设置文件提取码失败') + self.refresh() + else: + error('提取码为2-6位字符,关闭请输入off') + elif folder := self._dir_list.find_by_name(name): # 文件夹 + inf = self._disk.get_share_info(folder.id, False) + new_pass = input(f'修改提取码 "{inf.pwd or "无"}" -> ') + if 2 <= len(new_pass) <= 12: + if new_pass == 'off': new_pass = '' + if self._disk.set_passwd(folder.id, str(new_pass), False) != LanZouCloud.SUCCESS: + error('设置文件夹提取码失败') + self.refresh() + else: + error('提取码为2-12位字符,关闭请输入off') + else: + error(f'文件(夹)不存在: {name}') + + def desc(self, name): + """设置文件描述""" + if file := self._file_list.find_by_name(name): # 文件 + inf = self._disk.get_share_info(file.id, True) + print(f"当前描述: {inf.desc or '无'}") + desc = input(f'修改为 -> ') + if not desc: + error(f'文件描述不允许为空') + return None + if self._disk.set_desc(file.id, str(desc), True) != LanZouCloud.SUCCESS: + error(f'文件描述修改失败') + self.refresh() + elif folder := self._dir_list.find_by_name(name): # 文件夹 + inf = self._disk.get_share_info(folder.id, False) + print(f"当前描述: {inf.desc}") + desc = input(f'修改为 -> ') or '' + if self._disk.set_desc(folder.id, str(desc), False) == LanZouCloud.SUCCESS: + if len(desc) == 0: + info('文件夹描述已关闭') + else: + error(f'文件夹描述修改失败') + self.refresh() + else: + error(f'文件(夹)不存在: {name}') + + def setpath(self): + """设置下载路径""" + print(f"当前下载路径 : {config.save_path}") + path = input('修改为 -> ').strip("\"\' ") + if os.path.isdir(path): + config.save_path = path + else: + error('路径非法,取消修改') + + def setsize(self): + """设置上传限制""" + print(f"当前限制(MB): {config.max_size}") + max_size = input('修改为 -> ') + if not max_size.isnumeric(): + error("请输入大于 100 的数字") + return None + if self._disk.set_max_size(int(max_size)) != LanZouCloud.SUCCESS: + error("设置失败,限制值必需大于 100") + return None + config.max_size = int(max_size) + + def setdelay(self): + """设置大文件上传延时""" + print("大文件数据块上传延时范围(秒), 如: 0 60") + print(f"当前配置: {config.upload_delay}") + tr = input("请输入延时范围: ").split() + if len(tr) != 2: + error("格式有误!") + return None + tr = (int(tr[0]), int(tr[1])) + self._disk.set_upload_delay(tr) + config.upload_delay = tr + + def setpasswd(self): + """设置文件(夹)默认上传密码""" + print("关闭提取码请输入 off") + print(f"当前配置: 文件: {config.default_file_pwd or '无'}, 文件夹: {config.default_dir_pwd or '无'}") + file_pwd = input("设置文件默认提取码(2-6位): ") + if 2 <= len(file_pwd) <= 6: + config.default_file_pwd = '' if file_pwd == 'off' else file_pwd + dir_pwd = input("设置文件夹默认提取码(2-12位): ") + if 2 <= len(dir_pwd) <= 12: + config.default_dir_pwd = '' if dir_pwd == 'off' else dir_pwd + info(f"修改成功: 文件: {config.default_file_pwd or '无'}, 文件夹: {config.default_dir_pwd or '无'}, 配置将在下次启动时生效") + + def run(self): + """处理一条用户命令""" + no_arg_cmd = ['bye', 'cdrec', 'clear', 'help', 'login', 'logout', 'ls', 'refresh', 'rmode', 'setpath', + 'setsize', 'update', 'xghost', 'setdelay', 'setpasswd'] + cmd_with_arg = ['cd', 'desc', 'down', 'jobs', 'mkdir', 'mv', 'passwd', 'rename', 'rm', 'share', 'upload'] + + choice_list = self._file_list.all_name + self._dir_list.all_name + cmd_list = no_arg_cmd + cmd_with_arg + set_completer(choice_list, cmd_list=cmd_list) + + try: + args = input(self._prompt).split(' ', 1) + if len(args) == 0: + return None + except KeyboardInterrupt: + print('') + info('退出本程序请输入 bye') + return None + + cmd, arg = (args[0], '') if len(args) == 1 else (args[0], args[1]) # 命令, 参数(可带有空格, 没有参数就设为空) + + if cmd in no_arg_cmd: + getattr(self, cmd)() + elif cmd in cmd_with_arg: + getattr(self, cmd)(arg) diff --git a/lanzou/cmder/config.py b/lanzou/cmder/config.py new file mode 100644 index 0000000..ddaf13c --- /dev/null +++ b/lanzou/cmder/config.py @@ -0,0 +1,84 @@ +from pickle import load, dump + +__all__ = ['config'] + + +class Config: + + def __init__(self): + self._data = 'user.dat' + self._config = None + + with open(self._data, 'rb') as c: + self._config = load(c) + + def _save(self): + with open(self._data, 'wb') as c: + dump(self._config, c) + + @property + def cookie(self): + return self._config.get('cookie') + + @cookie.setter + def cookie(self, value): + self._config['cookie'] = value + self._save() + + @property + def save_path(self): + return self._config.get('path') + + @save_path.setter + def save_path(self, value): + self._config['path'] = value + self._save() + + @property + def upload_delay(self): + return self._config.get('upload_delay') + + @upload_delay.setter + def upload_delay(self, value): + self._config['upload_delay'] = value + self._save() + + @property + def default_file_pwd(self): + return self._config.get('default_file_pwd') + + @default_file_pwd.setter + def default_file_pwd(self, value): + self._config['default_file_pwd'] = value + self._save() + + @property + def default_dir_pwd(self): + return self._config.get('default_dir_pwd') + + @default_dir_pwd.setter + def default_dir_pwd(self, value): + self._config['default_dir_pwd'] = value + self._save() + + @property + def max_size(self): + return self._config.get('max_size') + + @max_size.setter + def max_size(self, value): + self._config['max_size'] = value + self._save() + + @property + def reader_mode(self): + return self._config.get('reader_mode') + + @reader_mode.setter + def reader_mode(self, value: bool): + self._config['reader_mode'] = value + self._save() + + +# 全局配置对象 +config = Config() diff --git a/lanzou/cmder/downloader.py b/lanzou/cmder/downloader.py new file mode 100644 index 0000000..5f9b109 --- /dev/null +++ b/lanzou/cmder/downloader.py @@ -0,0 +1,216 @@ +from enum import Enum +from threading import Thread + +from lanzou.api import LanZouCloud +from lanzou.api.utils import is_file_url, is_folder_url +from lanzou.cmder import config +from lanzou.cmder.utils import why_error + + +class TaskType(Enum): + """后台任务类型""" + UPLOAD = 0 + DOWNLOAD = 1 + + +class DownType(Enum): + """下载类型枚举类""" + INVALID_URL = 0 + FILE_URL = 1 + FOLDER_URL = 2 + FILE_ID = 3 + FOLDER_ID = 4 + + +class Downloader(Thread): + + def __init__(self, disk: LanZouCloud): + super(Downloader, self).__init__() + self._task_type = TaskType.DOWNLOAD + self._save_path = config.save_path + self._disk = disk + self._pid = -1 + self._down_type = None + self._down_args = None + self._f_path = None + self._now_size = 0 + self._total_size = 1 + self._err_msg = [] + + def _error_msg(self, msg): + """显示错误信息, 后台模式时保存信息而不显示""" + self._err_msg.append(msg) + + def set_task_id(self, pid): + """设置任务 id""" + self._pid = pid + + def get_task_id(self): + """获取当前任务 id""" + return self._pid + + def get_task_type(self): + """获取当前任务类型""" + return self._task_type + + def get_process(self) -> (int, int): + """获取下载进度""" + return self._now_size, self._total_size + + def get_cmd_info(self): + """获取命令行的信息""" + return self._down_args, self._f_path + + def get_err_msg(self) -> list: + """获取后台下载时保存的错误信息""" + return self._err_msg + + def set_url(self, url): + """设置 URL 下载任务""" + if is_file_url(url): # 如果是文件 + self._down_args = url + self._down_type = DownType.FILE_URL + elif is_folder_url(url): + self._down_args = url + self._down_type = DownType.FOLDER_URL + else: + self._down_type = DownType.INVALID_URL + + def set_fid(self, fid, is_file=True, f_path=None): + """设置 id 下载任务""" + self._down_args = fid + self._f_path = f_path # 文件(夹)名在网盘的路径 + self._down_type = DownType.FILE_ID if is_file else DownType.FOLDER_ID + + def _show_progress(self, file_name, total_size, now_size): + """更新下载进度的回调函数""" + self._total_size = total_size + self._now_size = now_size + + def _show_down_failed(self, code, file): + """文件下载失败时的回调函数""" + if hasattr(file, 'url'): + self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, URL: {file.url}") + else: + self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, ID: {file.id}") + + def run(self) -> None: + if self._down_type == DownType.INVALID_URL: + self._error_msg('(。>︿<) 该分享链接无效') + + elif self._down_type == DownType.FILE_URL: + code = self._disk.down_file_by_url(self._down_args, '', self._save_path, self._show_progress) + if code == LanZouCloud.LACK_PASSWORD: + pwd = input('输入该文件的提取码 : ') or '' + code2 = self._disk.down_file_by_url(self._down_args, str(pwd), self._save_path, self._show_progress) + if code2 != LanZouCloud.SUCCESS: + self._error_msg(f"文件下载失败: {why_error(code2)} -> {self._down_args}") + elif code != LanZouCloud.SUCCESS: + self._error_msg(f"文件下载失败: {why_error(code)} -> {self._down_args}") + + elif self._down_type == DownType.FOLDER_URL: + code = self._disk.down_dir_by_url(self._down_args, '', self._save_path, callback=self._show_progress, + mkdir=True, failed_callback=self._show_down_failed) + if code == LanZouCloud.LACK_PASSWORD: + pwd = input('输入该文件夹的提取码 : ') or '' + code2 = self._disk.down_dir_by_url(self._down_args, str(pwd), self._save_path, + callback=self._show_progress, + mkdir=True, failed_callback=self._show_down_failed) + if code2 != LanZouCloud.SUCCESS: + self._error_msg(f"文件夹下载失败: {why_error(code2)} -> {self._down_args}") + elif code != LanZouCloud.SUCCESS: + self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._down_args}") + + elif self._down_type == DownType.FILE_ID: + code = self._disk.down_file_by_id(self._down_args, self._save_path, self._show_progress) + if code != LanZouCloud.SUCCESS: + self._error_msg(f"文件下载失败: {why_error(code)} -> {self._f_path}") + + elif self._down_type == DownType.FOLDER_ID: + code = self._disk.down_dir_by_id(self._down_args, self._save_path, callback=self._show_progress, + mkdir=True, failed_callback=self._show_down_failed) + if code != LanZouCloud.SUCCESS: + self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._f_path} ") + + +class UploadType(Enum): + """上传类型枚举类""" + FILE = 0 + FOLDER = 1 + + +class Uploader(Thread): + + def __init__(self, disk: LanZouCloud): + super(Uploader, self).__init__() + self._task_type = TaskType.UPLOAD + self._disk = disk + self._pid = -1 + self._up_path = None + self._up_type = None + self._folder_id = -1 + self._folder_name = '' + self._now_size = 0 + self._total_size = 1 + self._err_msg = [] + self._default_file_pwd = config.default_file_pwd + self._default_dir_pwd = config.default_dir_pwd + + def _error_msg(self, msg): + self._err_msg.append(msg) + + def set_task_id(self, pid): + self._pid = pid + + def get_task_id(self): + return self._pid + + def get_task_type(self): + return self._task_type + + def get_process(self) -> (int, int): + return self._now_size, self._total_size + + def get_cmd_info(self): + return self._up_path, self._folder_name + + def get_err_msg(self) -> list: + return self._err_msg + + def set_upload_path(self, path, is_file=True): + """设置上传路径信息""" + self._up_path = path + self._up_type = UploadType.FILE if is_file else UploadType.FOLDER + + def set_target(self, folder_id=-1, folder_name=''): + """设置网盘保存文件夹信息""" + self._folder_id = folder_id + self._folder_name = folder_name + + def _show_progress(self, file_name, total_size, now_size): + self._total_size = total_size + self._now_size = now_size + + def _show_upload_failed(self, code, filename): + """文件下载失败时的回调函数""" + self._error_msg(f"上传失败: {why_error(code)} -> {filename}") + + def _set_pwd(self, fid, is_file): + """上传完成自动设置提取码""" + if is_file: + self._disk.set_passwd(fid, self._default_file_pwd, is_file=True) + else: + self._disk.set_passwd(fid, self._default_dir_pwd, is_file=False) + + def run(self) -> None: + if self._up_type == UploadType.FILE: + code = self._disk.upload_file(self._up_path, self._folder_id, callback=self._show_progress, + uploaded_handler=self._set_pwd) + if code != LanZouCloud.SUCCESS: + self._error_msg(f"文件上传失败: {why_error(code)} -> {self._up_path}") + + elif self._up_type == UploadType.FOLDER: + code = self._disk.upload_dir(self._up_path, self._folder_id, callback=self._show_progress, + failed_callback=self._show_upload_failed, uploaded_handler=self._set_pwd) + if code != LanZouCloud.SUCCESS: + self._error_msg(f"文件夹上传失败: {why_error(code)} -> {self._up_path}") diff --git a/lanzou/cmder/manager.py b/lanzou/cmder/manager.py new file mode 100644 index 0000000..e1e50fc --- /dev/null +++ b/lanzou/cmder/manager.py @@ -0,0 +1,85 @@ +from lanzou.cmder.downloader import TaskType +from lanzou.cmder.utils import info, error + +__all__ = ['global_task_mgr'] + + +class TaskManager(object): + """下载/上传任务管理器""" + + def __init__(self): + self._tasks = [] + + def is_empty(self): + """任务列表是否为空""" + return len(self._tasks) == 0 + + def has_alive_task(self): + """是否有任务在后台运行""" + for task in self._tasks: + if task.is_alive(): + return True + return False + + def add_task(self, task): + """提交一个上传/下载任务""" + for t in self._tasks: + if task.get_cmd_info() == t.get_cmd_info(): # 操作指令相同,认为是相同的任务 + old_pid = t.get_task_id() + if t.is_alive(): # 下载任务正在运行 + info(f"任务正在后台运行: PID {old_pid}") + return None + else: # 下载任务为 Finished 或 Error 状态 + choice = input(f"任务已完成, PID {old_pid}, 重试?(y)") + if choice.lower() == 'y': + task.set_task_id(old_pid) + self._tasks[old_pid] = task + task.start() + return None + # 没有发现重复的任务 + task.set_task_id(len(self._tasks)) + self._tasks.append(task) + task.start() + + @staticmethod + def _show_task(pid, task): + now_size, total_size = task.get_process() + percent = now_size / total_size * 100 + has_error = len(task.get_err_msg()) != 0 + if task.is_alive(): # 任务执行中 + status = '\033[1;32mRunning \033[0m' + elif not task.is_alive() and has_error: # 任务执行完成, 但是有错误信息 + status = '\033[1;31mError \033[0m' + else: # 任务正常执行完成 + status = '\033[1;34mFinished\033[0m' + if task.get_task_type() == TaskType.DOWNLOAD: + d_arg, f_name = task.get_cmd_info() + d_arg = f_name if type(d_arg) == int else d_arg # 显示 id 对应的文件名 + print(f"[{pid}] Status: {status} | Process: {percent:6.2f}% | Download: {d_arg}") + else: + up_path, folder_name = task.get_cmd_info() + print(f"[{pid}] Status: {status} | Process: {percent:6.2f}% | Upload: {up_path} -> {folder_name}") + + def show_tasks(self): + if self.is_empty(): + print(f"没有任务在后台运行哦") + else: + print('-' * 100) + for pid, task in enumerate(self._tasks): + self._show_task(pid, task) + print('-' * 100) + + def show_detail(self, pid=-1): + """显示任务详情""" + if 0 <= pid < len(self._tasks): + task = self._tasks[pid] + self._show_task(pid, task) + print("Error Messages:") + for msg in task.get_err_msg(): + print(msg) + else: + error(f"进程号不存在: PID {pid}") + + +# 全局任务管理器对象 +global_task_mgr = TaskManager() diff --git a/lanzou/cmder/recovery.py b/lanzou/cmder/recovery.py new file mode 100644 index 0000000..c19adff --- /dev/null +++ b/lanzou/cmder/recovery.py @@ -0,0 +1,109 @@ +from lanzou.cmder.utils import * +from lanzou.cmder import config + + +class Recovery: + """回收站命令行模式""" + + def __init__(self, disk: LanZouCloud): + self._prompt = 'Recovery > ' + self._reader_mode = config.reader_mode + self._disk = disk + + print("回收站数据加载中...") + self._file_list, self._folder_list = disk.get_rec_all() + + def ls(self): + if self._reader_mode: # 适宜屏幕阅读器的显示方式 + for file in self._file_list: + print(f"{file.name} 上传时间:{file.time}") + for folder in self._folder_list: + print(f"{folder.name}/ 创建时间:{folder.time}") + for i, file in enumerate(folder.files, 1): + print(f"{i}:{file.name} 大小:{file.size}") + print("") + else: # 普通用户的显示方式 + for file in self._file_list: + print("#{0:<12}{1:<14}{2}".format(file.id, file.time, file.name)) + for folder in self._folder_list: + print("#{0:<12}{1:<14}▣ {2}".format(folder.id, folder.time, folder.name)) + for i, file in enumerate(folder.files, 1): + if i == len(folder.files): + print("{0:<27}└─ [{1}]\t{2}".format('', file.size, file.name)) + else: + print("{0:<27}├─ [{1}]\t{2}".format('', file.size, file.name)) + + def clean(self): + """清空回收站""" + choice = input('确认清空回收站?(y) ') + if choice.lower() == 'y': + if self._disk.clean_rec() == LanZouCloud.SUCCESS: + self._file_list.clear() + self._folder_list.clear() + info('回收站清空成功!') + else: + error('回收站清空失败!') + + def rm(self, name): + """彻底删除文件(夹)""" + if file := self._file_list.find_by_name(name): # 删除文件 + if self._disk.delete_rec(file.id, is_file=True) == LanZouCloud.SUCCESS: + self._file_list.pop_by_id(file.id) + else: + error(f'彻底删除文件失败: {name}') + elif folder := self._folder_list.find_by_name(name): # 删除文件夹 + if self._disk.delete_rec(folder.id, is_file=False) == LanZouCloud.SUCCESS: + self._folder_list.pop_by_id(folder.id) + else: + error(f'彻底删除文件夹失败: {name}') + else: + error(f'文件(夹)不存在: {name}') + + def rec(self, name): + """恢复文件""" + if file := self._file_list.find_by_name(name): + if self._disk.recovery(file.id, True) == LanZouCloud.SUCCESS: + info(f"文件恢复成功: {name}") + self._file_list.pop_by_id(file.id) + else: + error(f'彻底删除文件失败: {name}') + elif folder := self._folder_list.find_by_name(name): # 删除文件夹 + if self._disk.recovery(folder.id, is_file=False) == LanZouCloud.SUCCESS: + info(f"文件夹恢复成功: {name}") + self._folder_list.pop_by_id(folder.id) + else: + error(f'彻底删除文件夹失败: {name}') + else: + error('(#`O′) 没有这个文件啊喂') + + def run(self): + """在回收站模式下运行""" + choice_list = self._file_list.all_name + self._folder_list.all_name + cmd_list = ['clean', 'cd', 'rec', 'rm'] + set_completer(choice_list, cmd_list=cmd_list) + + while True: + try: + args = input(self._prompt).split() + if len(args) == 0: + continue + except KeyboardInterrupt: + info('已退出回收站模式') + break + + cmd, arg = args[0], ' '.join(args[1:]) + + if cmd == 'ls': + self.ls() + elif cmd == 'clean': + self.clean() + elif cmd == 'rec': + self.rec(arg) + elif cmd == 'rm': + self.rm(arg) + elif cmd == 'cd' and arg == '..': + print('') + info('已退出回收站模式') + break + else: + info('使用 cd .. 或 Crtl + C 退出回收站') diff --git a/lanzou/cmder/utils.py b/lanzou/cmder/utils.py new file mode 100644 index 0000000..8ed5c6b --- /dev/null +++ b/lanzou/cmder/utils.py @@ -0,0 +1,193 @@ +import os +from platform import system as platform + +import readline +import requests + +from lanzou.api import LanZouCloud +from lanzou.cmder import version + + +def error(msg): + print(f"\033[1;31mError : {msg}\033[0m") + + +def info(msg): + print(f"\033[1;34mInfo : {msg}\033[0m") + + +def clear_screen(): + """清空屏幕""" + if os.name == 'nt': + os.system('cls') + else: + os.system('clear') + + +def why_error(code): + """错误原因""" + if code == LanZouCloud.URL_INVALID: + return '分享链接无效' + elif code == LanZouCloud.LACK_PASSWORD: + return '缺少提取码' + elif code == LanZouCloud.PASSWORD_ERROR: + return '提取码错误' + elif code == LanZouCloud.FILE_CANCELLED: + return '分享链接已失效' + elif code == LanZouCloud.ZIP_ERROR: + return '解压过程异常' + elif code == LanZouCloud.NETWORK_ERROR: + return '网络连接异常' + elif code == LanZouCloud.CAPTCHA_ERROR: + return '验证码错误' + elif code == LanZouCloud.OFFICIAL_LIMITED: + return '操作被官方限制' + else: + return '未知错误' + + +def set_console_style(): + """设置命令行窗口样式""" + if os.name != 'nt': + return None + os.system('mode 120, 40') + os.system(f'title 蓝奏云 CMD 控制台 {version}') + + +def captcha_handler(img_data): + """处理下载时出现的验证码""" + img_path = os.getcwd() + os.sep + 'captcha.png' + with open(img_path, 'wb') as f: + f.write(img_data) + m_platform = platform() + if m_platform == 'Darwin': + os.system(f'open {img_path}') + elif m_platform == 'Linux': + os.system(f'xdg-open {img_path}') + else: + os.startfile(img_path) + ans = input('\n请输入验证码:') + os.remove(img_path) + return ans + + +def text_align(text, length) -> str: + """中英混合字符串对齐""" + text_len = len(text) + for char in text: + if u'\u4e00' <= char <= u'\u9fff': + text_len += 1 + space = length - text_len + return text + ' ' * space + + +def set_completer(choice_list, *, cmd_list=None, condition=None): + """设置自动补全""" + if condition is None: + condition = lambda typed, choice: choice.startswith(typed) # 默认筛选条件:选项以键入字符开头 + + def completer(typed, rank): + tab_list = [] # TAB 补全的选项列表 + if cmd_list is not None and not typed: # 内置命令提示 + return cmd_list[rank] + + for choice in choice_list: + if condition(typed, choice): + tab_list.append(choice) + return tab_list[rank] + + readline.parse_and_bind("tab: complete") + readline.set_completer(completer) + + +def print_logo(): + """输出logo""" + clear_screen() + logo_str = f""" + _ ______ _____ _ _ + | | |___ / / __ \ | | | + | | __ _ _ __ / / ___ _ _| / \/ | ___ _ _ __| | + | | / _ | _ \ / / / _ \| | | | | | |/ _ \| | | |/ _ | + | |___| (_| | | | | / /__| (_) | |_| | \__/\ | (_) | |_| | (_| | + \_____/\____|_| |_|\_____/\___/ \____|\____/_|\___/ \____|\____| + -------------------------------------------------------------------- + Github: https://github.com/zaxtyson/LanZouCloud-CMD (Version: {version}) + -------------------------------------------------------------------- + """ + print(logo_str) + + +def print_help(): + clear_screen() + help_text = f""" + • CMD 版蓝奏云控制台 v{version} + + 命令帮助 : + help 显示本信息 + update 检查更新 + rmode 屏幕阅读器模式 + refresh 强制刷新文件列表 + xghost 清理"幽灵"文件夹 + login 使用 Cookie 登录网盘 + logout 注销当前账号 + jobs 查看后台任务列表 + ls 列出文件(夹) + cd 切换工作目录 + cdrec 进入回收站 + rm 删除网盘文件(夹) + rename 重命名文件(夹) + desc 修改文件(夹)描述 + mv 移动文件(夹) + mkdir 创建新文件夹(最大深度 4) + share 显示文件(夹)分享信息 + clear 清空屏幕 + clean 清空回收站 + upload 上传文件(夹) + down 下载文件(夹),支持 URL 下载 + passwd 设置文件(夹)提取码 + setpath 设置文件下载路径 + setsize 设置单文件大小限制 + setpasswd 设置文件(夹)默认提取码 + setdelay 设置上传大文件数据块的延时 + bye 退出本程序 + + 更详细的介绍请参考本项目的 Github 主页: + https://github.com/zaxtyson/LanZouCloud-CMD + 如有 Bug 反馈或建议请在 GitHub 提 Issue 或者 + 发送邮件至 : zaxtyson@foxmail.com + 感谢您的使用 (●'◡'●) + """ + print(help_text) + + +def check_update(): + """检查更新""" + clear_screen() + print("正在检测更新...") + api = "https://api.github.com/repos/zaxtyson/LanZouCloud-CMD/releases/latest" + try: + resp = requests.get(api).json() + except (requests.RequestException, AttributeError): + error("检查更新时发生异常") + input() + return None + tag_name, msg = resp['tag_name'], resp['body'] + update_url = resp['assets'][0]['browser_download_url'] + ver = version.split('.') + ver2 = tag_name.replace('v', '').split('.') + local_version = int(ver[0]) * 100 + int(ver[1]) * 10 + int(ver[2]) + remote_version = int(ver2[0]) * 100 + int(ver2[1]) * 10 + int(ver2[2]) + if remote_version > local_version: + print(f"程序可以更新 v{version} -> {tag_name}") + print(f"\n@更新说明:\n{msg}") + print(f"\n@Windows 更新:") + print(f"蓝奏云: https://www.lanzous.com/b0f14h1od") + print(f"Github: {update_url}") + print("\n@Linux 更新:") + input("git clone https://github.com/zaxtyson/LanZouCloud-CMD.git") + else: + print("(*/ω\*) 暂无新版本发布~") + print("但项目可能已经更新,建议去项目主页看看") + print("如有 Bug 或建议,请提 Issue 或发邮件反馈") + print("Email: zaxtyson@foxmail.com") + print("Github: https://github.com/zaxtyson/LanZouCloud-CMD") diff --git a/lanzou_cmd.py b/lanzou_cmd.py new file mode 100644 index 0000000..87a2ea6 --- /dev/null +++ b/lanzou_cmd.py @@ -0,0 +1,17 @@ +from lanzou.cmder.cmder import Commander +from lanzou.cmder.utils import * + +if __name__ == '__main__': + set_console_style() + check_update() + print_logo() + commander = Commander() + commander.login() + + while True: + try: + commander.run() + except KeyboardInterrupt: + pass + except Exception as e: + error(e) diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b6bc4316f6f77810bd5c9225f1da44bef1c0ae8 GIT binary patch literal 107593 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 literal 0 HcmV?d00001 diff --git a/user.dat b/user.dat new file mode 100644 index 0000000000000000000000000000000000000000..8fc983cecade7e8ca34a883e5ee4b06c1ef2f3a8 GIT binary patch literal 137 zcmZo*nOesH0ku;!df1Zl^RqKkr}*`-6eN~pOzGj$(|5@)&&$bAOqtTdk(*c%Uz}N$ zI>kGshr1{>F(tJqJ~uxlbxLOsPiX;2QG7~jPGaR0ZzgY+wkbXQDXD3Rr8y<>DVas_ Y1?4~^7^d_Hzy#AWb5g;g8Jwkh01S#U)Bpeg literal 0 HcmV?d00001