├── lanzou ├── __init__.py ├── cmder │ ├── __init__.py │ ├── config.py │ ├── manager.py │ ├── recovery.py │ ├── utils.py │ ├── downloader.py │ ├── cmder.py │ └── browser_cookie.py └── api │ ├── __init__.py │ ├── types.py │ ├── models.py │ ├── utils.py │ └── core.py ├── .gitignore ├── logo.ico ├── user.dat ├── inno_setup.iss ├── requirements.txt ├── main.py └── README.md /lanzou/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['api', 'cmder'] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | __pycache__ 4 | dist 5 | build -------------------------------------------------------------------------------- /logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaxtyson/LanZouCloud-CMD/HEAD/logo.ico -------------------------------------------------------------------------------- /user.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaxtyson/LanZouCloud-CMD/HEAD/user.dat -------------------------------------------------------------------------------- /inno_setup.iss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaxtyson/LanZouCloud-CMD/HEAD/inno_setup.iss -------------------------------------------------------------------------------- /lanzou/cmder/__init__.py: -------------------------------------------------------------------------------- 1 | from lanzou.cmder.config import config 2 | 3 | version = '2.6.8' 4 | 5 | __all__ = ['cmder', 'utils', 'version', 'config'] 6 | -------------------------------------------------------------------------------- /lanzou/api/__init__.py: -------------------------------------------------------------------------------- 1 | from lanzou.api.core import LanZouCloud 2 | 3 | version = '2.6.8' 4 | 5 | __all__ = ['utils', 'types', 'models', 'LanZouCloud', 'version'] 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.4.5.1 2 | cffi==1.14.5 3 | chardet==3.0.4 4 | cryptography==3.4.6 5 | idna==2.9 6 | jeepney==0.6.0 7 | keyring==22.3.0 8 | lz4==3.1.3 9 | pbkdf2==1.3 10 | pyaes==1.6.1 11 | pycparser==2.20 12 | pycryptodome==3.10.1 13 | pyreadline==2.1 14 | pywin32-ctypes==0.2.0 15 | requests==2.26.0 16 | requests-toolbelt==0.9.1 17 | SecretStorage==3.3.1 18 | urllib3==1.26.7 19 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from lanzou.cmder.cmder import Commander 2 | from lanzou.cmder.utils import * 3 | 4 | if __name__ == '__main__': 5 | set_console_style() 6 | check_update() 7 | print_logo() 8 | show_tips_first() 9 | commander = Commander() 10 | commander.login() 11 | 12 | while True: 13 | try: 14 | commander.run() 15 | except KeyboardInterrupt: 16 | pass 17 | except Exception as e: 18 | error(e) 19 | -------------------------------------------------------------------------------- /lanzou/api/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | API 处理后返回的数据类型 3 | """ 4 | 5 | from collections import namedtuple 6 | 7 | File = namedtuple('File', ['name', 'id', 'time', 'size', 'type', 'downs', 'has_pwd', 'has_des']) 8 | Folder = namedtuple('Folder', ['name', 'id', 'has_pwd', 'desc']) 9 | FolderId = namedtuple('FolderId', ['name', 'id']) 10 | RecFile = namedtuple('RecFile', ['name', 'id', 'type', 'size', 'time']) 11 | RecFolder = namedtuple('RecFolder', ['name', 'id', 'size', 'time', 'files']) 12 | FileDetail = namedtuple('FileDetail', ['code', 'name', 'size', 'type', 'time', 'desc', 'pwd', 'url', 'durl'], 13 | defaults=(0, *[''] * 8)) 14 | ShareInfo = namedtuple('ShareInfo', ['code', 'name', 'url', 'pwd', 'desc'], defaults=(0, *[''] * 4)) 15 | DirectUrlInfo = namedtuple('DirectUrlInfo', ['code', 'name', 'durl']) 16 | FolderInfo = namedtuple('Folder', ['name', 'id', 'pwd', 'time', 'desc', 'url'], defaults=('',) * 6) 17 | FileInFolder = namedtuple('FileInFolder', ['name', 'time', 'size', 'type', 'url'], defaults=('',) * 5) 18 | FolderDetail = namedtuple('FolderDetail', ['code', 'folder', 'files', 'sub_folders'], defaults=(0, None, None, None)) 19 | -------------------------------------------------------------------------------- /lanzou/cmder/config.py: -------------------------------------------------------------------------------- 1 | from os.path import expanduser, sep 2 | from pickle import load, dump 3 | 4 | __all__ = ['config'] 5 | 6 | 7 | class Config: 8 | 9 | def __init__(self): 10 | self._data = 'user.dat' 11 | self._config = None 12 | 13 | with open(self._data, 'rb') as c: 14 | self._config = load(c) 15 | 16 | def _save(self): 17 | with open(self._data, 'wb') as c: 18 | dump(self._config, c) 19 | 20 | @property 21 | def cookie(self): 22 | return self._config.get('cookie') 23 | 24 | @cookie.setter 25 | def cookie(self, value): 26 | self._config['cookie'] = value 27 | self._save() 28 | 29 | @property 30 | def save_path(self): 31 | path = self._config.get('path') 32 | if not path: 33 | return expanduser("~") + sep + "Downloads" 34 | return path 35 | 36 | @save_path.setter 37 | def save_path(self, value): 38 | self._config['path'] = value 39 | self._save() 40 | 41 | @property 42 | def upload_delay(self): 43 | return self._config.get('upload_delay') 44 | 45 | @upload_delay.setter 46 | def upload_delay(self, value): 47 | self._config['upload_delay'] = value 48 | self._save() 49 | 50 | @property 51 | def default_file_pwd(self): 52 | return self._config.get('default_file_pwd') 53 | 54 | @default_file_pwd.setter 55 | def default_file_pwd(self, value): 56 | self._config['default_file_pwd'] = value 57 | self._save() 58 | 59 | @property 60 | def default_dir_pwd(self): 61 | return self._config.get('default_dir_pwd') 62 | 63 | @default_dir_pwd.setter 64 | def default_dir_pwd(self, value): 65 | self._config['default_dir_pwd'] = value 66 | self._save() 67 | 68 | @property 69 | def max_size(self): 70 | return self._config.get('max_size') 71 | 72 | @max_size.setter 73 | def max_size(self, value): 74 | self._config['max_size'] = value 75 | self._save() 76 | 77 | @property 78 | def reader_mode(self): 79 | return self._config.get('reader_mode') 80 | 81 | @reader_mode.setter 82 | def reader_mode(self, value: bool): 83 | self._config['reader_mode'] = value 84 | self._save() 85 | 86 | 87 | # 全局配置对象 88 | config = Config() 89 | -------------------------------------------------------------------------------- /lanzou/api/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | 容器类,用于储存文件、文件夹,支持 list 的操作,同时支持许多方法方便操作元素 3 | 元素类型为 namedtuple,至少拥有 name id 两个属性才能放入容器 4 | """ 5 | 6 | __all__ = ['FileList', 'FolderList'] 7 | 8 | 9 | class ItemList: 10 | """具有 name, id 属性对象的列表""" 11 | 12 | def __init__(self): 13 | self._items = [] 14 | 15 | def __len__(self): 16 | return len(self._items) 17 | 18 | def __getitem__(self, index): 19 | return self._items[index] 20 | 21 | def __iter__(self): 22 | return iter(self._items) 23 | 24 | def __repr__(self): 25 | return f"" 26 | 27 | def __lt__(self, other): 28 | """用于路径 List 之间排序""" 29 | return '/'.join(i.name for i in self) < '/'.join(i.name for i in other) 30 | 31 | @property 32 | def name_id(self): 33 | """所有 item 的 name-id 列表,兼容旧版""" 34 | return {it.name: it.id for it in self} 35 | 36 | @property 37 | def all_name(self): 38 | """所有 item 的 name 列表""" 39 | return [it.name for it in self] 40 | 41 | def append(self, item): 42 | """在末尾插入元素""" 43 | self._items.append(item) 44 | 45 | def index(self, item): 46 | """获取索引""" 47 | return self._items.index(item) 48 | 49 | def insert(self, pos, item): 50 | """指定位置插入元素""" 51 | self._items.insert(pos, item) 52 | 53 | def clear(self): 54 | """清空元素""" 55 | self._items.clear() 56 | 57 | def filter(self, condition) -> list: 58 | """筛选出满足条件的 item 59 | condition(item) -> True 60 | """ 61 | return [it for it in self if condition(it)] 62 | 63 | def find_by_name(self, name: str): 64 | """使用文件名搜索(仅返回首个匹配项)""" 65 | for item in self: 66 | if name == item.name: 67 | return item 68 | return None 69 | 70 | def find_by_id(self, fid: int): 71 | """使用 id 搜索(精确)""" 72 | for item in self: 73 | if fid == item.id: 74 | return item 75 | return None 76 | 77 | def pop_by_id(self, fid): 78 | for item in self: 79 | if item.id == fid: 80 | self._items.remove(item) 81 | return item 82 | return None 83 | 84 | def update_by_id(self, fid, **kwargs): 85 | """通过 id 搜索元素并更新""" 86 | item = self.find_by_id(fid) 87 | pos = self.index(item) 88 | data = item._asdict() 89 | data.update(kwargs) 90 | self._items[pos] = item.__class__(**data) 91 | 92 | 93 | class FileList(ItemList): 94 | """文件列表类""" 95 | pass 96 | 97 | 98 | class FolderList(ItemList): 99 | """文件夹列表类""" 100 | pass 101 | -------------------------------------------------------------------------------- /lanzou/cmder/manager.py: -------------------------------------------------------------------------------- 1 | from lanzou.cmder.downloader import TaskType 2 | from lanzou.cmder.utils import info, error 3 | 4 | __all__ = ['global_task_mgr'] 5 | 6 | 7 | class TaskManager(object): 8 | """下载/上传任务管理器""" 9 | 10 | def __init__(self): 11 | self._tasks = [] 12 | 13 | def is_empty(self): 14 | """任务列表是否为空""" 15 | return len(self._tasks) == 0 16 | 17 | def has_alive_task(self): 18 | """是否有任务在后台运行""" 19 | for task in self._tasks: 20 | if task.is_alive(): 21 | return True 22 | return False 23 | 24 | def add_task(self, task): 25 | """提交一个上传/下载任务""" 26 | for t in self._tasks: 27 | if task.get_cmd_info() == t.get_cmd_info(): # 操作指令相同,认为是相同的任务 28 | old_pid = t.get_task_id() 29 | if t.is_alive(): # 下载任务正在运行 30 | info(f"任务正在后台运行: PID {old_pid}") 31 | return None 32 | else: # 下载任务为 Finished 或 Error 状态 33 | choice = input(f"任务已完成, PID {old_pid}, 重试?(y)") 34 | if choice.lower() == 'y': 35 | task.set_task_id(old_pid) 36 | self._tasks[old_pid] = task 37 | task.start() 38 | return None 39 | # 没有发现重复的任务 40 | task.set_task_id(len(self._tasks)) 41 | self._tasks.append(task) 42 | task.start() 43 | 44 | @staticmethod 45 | def _get_task_status(task): 46 | now_size, total_size = task.get_process() 47 | percent = now_size / total_size * 100 48 | has_error = len(task.get_err_msg()) != 0 49 | if task.is_alive(): # 任务执行中 50 | status = '\033[1;32m运行中\033[0m' 51 | elif not task.is_alive() and has_error: # 任务执行完成, 但是有错误信息 52 | status = '\033[1;31m出错 \033[0m' 53 | else: # 任务正常执行完成 54 | status = '\033[1;34m已完成\033[0m' 55 | return percent, status 56 | 57 | def show_tasks(self): 58 | if self.is_empty(): 59 | print(f"没有任务在后台运行哦") 60 | return 61 | 62 | print('-' * 100) 63 | for pid, task in enumerate(self._tasks): 64 | percent, status = self._get_task_status(task) 65 | if task.get_task_type() == TaskType.DOWNLOAD: 66 | d_arg, f_name = task.get_cmd_info() 67 | d_arg = f_name if type(d_arg) == int else d_arg # 显示 id 对应的文件名 68 | print(f"ID: {pid} | 状态: {status} | 进度: {percent:6.2f}% | 下载: {d_arg}") 69 | else: 70 | up_path, folder_name = task.get_cmd_info() 71 | print(f"ID: {pid} | 状态: {status} | 进度: {percent:6.2f}% | 上传: {up_path} -> {folder_name}") 72 | print('-' * 100) 73 | 74 | def show_detail(self, pid=-1): 75 | """显示任务详情""" 76 | if pid < 0 or pid >= len(self._tasks): 77 | error(f"进程号不存在: PID {pid}") 78 | return 79 | 80 | task = self._tasks[pid] 81 | percent, status = self._get_task_status(task) 82 | print('-' * 60) 83 | print(f"进程ID号: {pid}") 84 | print(f"任务状态: {status}") 85 | print(f"任务类型: {'下载' if task.get_task_type() == TaskType.DOWNLOAD else '上传'}") 86 | print(f"任务进度: {percent:.2f}%") 87 | print("错误信息:") 88 | if not task.get_err_msg(): 89 | print("\t没有错误, 一切正常 :)") 90 | print('-' * 60) 91 | return 92 | # 显示出错信息 93 | for msg in task.get_err_msg(): 94 | print("\t" + msg) 95 | print('-' * 60) 96 | 97 | 98 | # 全局任务管理器对象 99 | global_task_mgr = TaskManager() 100 | -------------------------------------------------------------------------------- /lanzou/cmder/recovery.py: -------------------------------------------------------------------------------- 1 | from lanzou.cmder.utils import * 2 | 3 | 4 | class Recovery: 5 | """回收站命令行模式""" 6 | 7 | def __init__(self, disk: LanZouCloud): 8 | self._prompt = 'Recovery > ' 9 | self._reader_mode = config.reader_mode 10 | self._disk = disk 11 | 12 | print("回收站数据加载中...") 13 | self._file_list, self._folder_list = disk.get_rec_all() 14 | 15 | def ls(self): 16 | if self._reader_mode: # 适宜屏幕阅读器的显示方式 17 | for file in self._file_list: 18 | print(f"{file.name} 上传时间:{file.time}") 19 | for folder in self._folder_list: 20 | print(f"{folder.name}/ 创建时间:{folder.time}") 21 | for i, file in enumerate(folder.files, 1): 22 | print(f"{i}:{file.name} 大小:{file.size}") 23 | print("") 24 | else: # 普通用户的显示方式 25 | for file in self._file_list: 26 | print("#{0:<12}{1:<14}{2}".format(file.id, file.time, file.name)) 27 | for folder in self._folder_list: 28 | print("#{0:<12}{1:<14}▣ {2}".format(folder.id, folder.time, folder.name)) 29 | for i, file in enumerate(folder.files, 1): 30 | if i == len(folder.files): 31 | print("{0:<27}└─ [{1}]\t{2}".format('', file.size, file.name)) 32 | else: 33 | print("{0:<27}├─ [{1}]\t{2}".format('', file.size, file.name)) 34 | 35 | def clean(self): 36 | """清空回收站""" 37 | choice = input('确认清空回收站?(y) ') 38 | if choice.lower() == 'y': 39 | if self._disk.clean_rec() == LanZouCloud.SUCCESS: 40 | self._file_list.clear() 41 | self._folder_list.clear() 42 | info('回收站清空成功!') 43 | else: 44 | error('回收站清空失败!') 45 | 46 | def rm(self, name): 47 | """彻底删除文件(夹)""" 48 | if file := self._file_list.find_by_name(name): # 删除文件 49 | if self._disk.delete_rec(file.id, is_file=True) == LanZouCloud.SUCCESS: 50 | self._file_list.pop_by_id(file.id) 51 | else: 52 | error(f'彻底删除文件失败: {name}') 53 | elif folder := self._folder_list.find_by_name(name): # 删除文件夹 54 | if self._disk.delete_rec(folder.id, is_file=False) == LanZouCloud.SUCCESS: 55 | self._folder_list.pop_by_id(folder.id) 56 | else: 57 | error(f'彻底删除文件夹失败: {name}') 58 | else: 59 | error(f'文件(夹)不存在: {name}') 60 | 61 | def rec(self, name): 62 | """恢复文件""" 63 | if file := self._file_list.find_by_name(name): 64 | if self._disk.recovery(file.id, True) == LanZouCloud.SUCCESS: 65 | info(f"文件恢复成功: {name}") 66 | self._file_list.pop_by_id(file.id) 67 | else: 68 | error(f'彻底删除文件失败: {name}') 69 | elif folder := self._folder_list.find_by_name(name): # 删除文件夹 70 | if self._disk.recovery(folder.id, is_file=False) == LanZouCloud.SUCCESS: 71 | info(f"文件夹恢复成功: {name}") 72 | self._folder_list.pop_by_id(folder.id) 73 | else: 74 | error(f'彻底删除文件夹失败: {name}') 75 | else: 76 | error('(#`O′) 没有这个文件啊喂') 77 | 78 | def run(self): 79 | """在回收站模式下运行""" 80 | choice_list = self._file_list.all_name + self._folder_list.all_name 81 | cmd_list = ['clean', 'cd', 'rec', 'rm'] 82 | set_completer(choice_list, cmd_list=cmd_list) 83 | 84 | while True: 85 | try: 86 | args = input(self._prompt).split() 87 | if len(args) == 0: 88 | continue 89 | except KeyboardInterrupt: 90 | info('已退出回收站模式') 91 | break 92 | 93 | cmd, arg = args[0], ' '.join(args[1:]) 94 | 95 | if cmd == 'ls': 96 | self.ls() 97 | elif cmd == 'clean': 98 | self.clean() 99 | elif cmd == 'rec': 100 | self.rec(arg) 101 | elif cmd == 'rm': 102 | self.rm(arg) 103 | elif cmd == 'cd' and arg == '..': 104 | print('') 105 | info('已退出回收站模式') 106 | break 107 | else: 108 | info('使用 cd .. 或 Crtl + C 退出回收站') 109 | -------------------------------------------------------------------------------- /lanzou/cmder/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from time import sleep 3 | 4 | import readline 5 | import requests 6 | 7 | from lanzou.api import LanZouCloud 8 | from lanzou.cmder import version, config 9 | 10 | 11 | def error(msg, end='\n'): 12 | print(f"\033[1;31mError : {msg}\033[0m", end=end) 13 | 14 | 15 | def info(msg, end='\n'): 16 | print(f"\033[1;34mInfo : {msg}\033[0m", end=end) 17 | 18 | 19 | def clear_screen(): 20 | """清空屏幕""" 21 | if os.name == 'nt': 22 | os.system('cls') 23 | else: 24 | os.system('clear') 25 | 26 | 27 | def why_error(code): 28 | """错误原因""" 29 | if code == LanZouCloud.URL_INVALID: 30 | return '分享链接无效' 31 | elif code == LanZouCloud.LACK_PASSWORD: 32 | return '缺少提取码' 33 | elif code == LanZouCloud.PASSWORD_ERROR: 34 | return '提取码错误' 35 | elif code == LanZouCloud.FILE_CANCELLED: 36 | return '分享链接已失效' 37 | elif code == LanZouCloud.ZIP_ERROR: 38 | return '解压过程异常' 39 | elif code == LanZouCloud.NETWORK_ERROR: 40 | return '网络连接异常' 41 | elif code == LanZouCloud.CAPTCHA_ERROR: 42 | return '验证码错误' 43 | elif code == LanZouCloud.OFFICIAL_LIMITED: 44 | return '操作被官方限制' 45 | else: 46 | return '未知错误' 47 | 48 | 49 | def ignore_limit(): 50 | return os.path.exists(".ignore_limit") 51 | 52 | 53 | def set_console_style(): 54 | """设置命令行窗口样式""" 55 | if os.name != 'nt': 56 | return None 57 | # os.system('mode 120, 40') 58 | os.system(f'title 蓝奏云 CMD 控制台 {version}') 59 | 60 | 61 | def text_align(text, length) -> str: 62 | """中英混合字符串对齐""" 63 | text_len = len(text) 64 | for char in text: 65 | if u'\u4e00' <= char <= u'\u9fff': 66 | text_len += 1 67 | space = length - text_len 68 | return text + ' ' * space 69 | 70 | 71 | def set_completer(choice_list, *, cmd_list=None, condition=None): 72 | """设置自动补全""" 73 | if condition is None: 74 | condition = lambda typed, choice: choice.startswith(typed) # 默认筛选条件:选项以键入字符开头 75 | 76 | def completer(typed, rank): 77 | tab_list = [] # TAB 补全的选项列表 78 | if cmd_list is not None and not typed: # 内置命令提示 79 | return cmd_list[rank] 80 | 81 | for choice in choice_list: 82 | if condition(typed, choice): 83 | tab_list.append(choice) 84 | return tab_list[rank] 85 | 86 | readline.parse_and_bind("tab: complete") 87 | readline.set_completer(completer) 88 | 89 | 90 | def print_logo(): 91 | """输出logo""" 92 | clear_screen() 93 | ext_msg = "Unlimited " if ignore_limit() else "" 94 | logo_str = f""" 95 | _ ______ _____ _ _ 96 | | | |___ / / __ \ | | | 97 | | | __ _ _ __ / / ___ _ _| / \/ | ___ _ _ __| | 98 | | | / _ | _ \ / / / _ \| | | | | | |/ _ \| | | |/ _ | 99 | | |___| (_| | | | | / /__| (_) | |_| | \__/\ | (_) | |_| | (_| | 100 | \_____/\____|_| |_|\_____/\___/ \____|\____/_|\___/ \____|\____| 101 | -------------------------------------------------------------------- 102 | Github: https://github.com/zaxtyson/LanZouCloud-CMD ({ext_msg}Version: {version}) 103 | -------------------------------------------------------------------- 104 | """ 105 | print(logo_str) 106 | 107 | 108 | def print_help(): 109 | clear_screen() 110 | help_text = f""" 111 | • CMD 版蓝奏云控制台 v{version} 112 | 113 | 命令帮助 : 114 | help 显示本信息 115 | update 检查更新 116 | rmode 屏幕阅读器模式 117 | refresh 刷新当前目录 118 | xghost 清理"幽灵"文件夹 119 | login 使用 Cookie 登录网盘 120 | logout 注销当前账号 121 | jobs 查看后台任务列表 122 | jobs 查看 ID 对应任务的详情(失败原因) 123 | ls 列出文件(夹) 124 | cd 切换工作目录 125 | cdrec 进入回收站 126 | rm 删除网盘文件(夹) 127 | rename 重命名文件(夹) 128 | desc 修改文件(夹)描述 129 | mv 移动文件(夹) 130 | mkdir 创建新文件夹(最大深度 4) 131 | share 显示文件(夹)分享信息 132 | export 导出文件夹下的文件信息到文件 133 | clear 清空屏幕 134 | clean 清空回收站 135 | upload 上传文件(夹), 大文件上传功能已关闭 136 | down 下载文件(夹), 支持 URL, 支持递归下载 137 | passwd 设置文件(夹)提取码 138 | setpath 设置文件下载路径 139 | setsize 设置单文件大小限制 140 | setpasswd 设置文件(夹)默认提取码 141 | setdelay 设置上传大文件数据块的延时 142 | bye 退出本程序 143 | 144 | 更详细的介绍请参考本项目的 Github 主页: 145 | https://github.com/zaxtyson/LanZouCloud-CMD 146 | 如有 Bug 反馈或建议请在 GitHub 提 Issue 或者 147 | 发送邮件至 : zaxtyson@foxmail.com 148 | 感谢您的使用 (●'◡'●) 149 | """ 150 | print(help_text) 151 | 152 | 153 | def check_update(): 154 | """检查更新""" 155 | clear_screen() 156 | print("正在检测更新...") 157 | api = "https://api.github.com/repos/zaxtyson/LanZouCloud-CMD/releases/latest" 158 | try: 159 | resp = requests.get(api, timeout=3).json() 160 | tag_name, msg = resp['tag_name'], resp['body'] 161 | update_url = resp['assets'][0]['browser_download_url'] 162 | ver = version.split('.') 163 | ver2 = tag_name.replace('v', '').split('.') 164 | local_version = int(ver[0]) * 100 + int(ver[1]) * 10 + int(ver[2]) 165 | remote_version = int(ver2[0]) * 100 + int(ver2[1]) * 10 + int(ver2[2]) 166 | if remote_version > local_version: 167 | "\033[1;34mInfo : {msg}\033[0m" 168 | print(f"\n程序可以更新 v{version} -> \033[1;32m{tag_name}\033[0m") 169 | print(f"\n# 更新说明\n\n{msg}") 170 | print(f"\n# Windows 更新\n") 171 | print(f"蓝奏云: https://zaxtyson.lanzouf.com/b0f14h1od") 172 | print(f"Github: {update_url}") 173 | print("\n# Linux 更新\n") 174 | print("git pull --rebase") 175 | else: 176 | print("\n(*/ω\*) 暂无新版本发布~") 177 | print("但项目可能已经更新,建议去项目主页看看") 178 | print("如有 Bug 或建议,请提 Issue 或发邮件反馈\n") 179 | print("Email: zaxtyson@foxmail.com") 180 | print("Github: https://github.com/zaxtyson/LanZouCloud-CMD") 181 | print() 182 | except (requests.RequestException, AttributeError, KeyError): 183 | error("检查更新时发生异常") 184 | sleep(2) 185 | return 186 | except TimeoutError: 187 | error("检查更新超时, 请稍后重试") 188 | sleep(2) 189 | return 190 | 191 | 192 | def show_tips_first(): 193 | """第一次启动时的提醒""" 194 | if not config.cookie: 195 | info(f"下载文件保存路径为: {config.save_path}") 196 | info("使用 setpath 命令可修改保存路径") 197 | info("其它帮助信息请使用 help 命令查看\n") 198 | -------------------------------------------------------------------------------- /lanzou/cmder/downloader.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from threading import Thread 3 | 4 | from lanzou.api import LanZouCloud 5 | from lanzou.api.utils import is_file_url, is_folder_url 6 | from lanzou.cmder import config 7 | from lanzou.cmder.utils import why_error 8 | 9 | 10 | class TaskType(Enum): 11 | """后台任务类型""" 12 | UPLOAD = 0 13 | DOWNLOAD = 1 14 | 15 | 16 | class DownType(Enum): 17 | """下载类型枚举类""" 18 | INVALID_URL = 0 19 | FILE_URL = 1 20 | FOLDER_URL = 2 21 | FILE_ID = 3 22 | FOLDER_ID = 4 23 | 24 | 25 | class Downloader(Thread): 26 | 27 | def __init__(self, disk: LanZouCloud): 28 | super(Downloader, self).__init__() 29 | self._task_type = TaskType.DOWNLOAD 30 | self._save_path = config.save_path 31 | self._disk = disk 32 | self._pid = -1 33 | self._down_type = None 34 | self._down_args = None 35 | self._f_path = None 36 | self._now_size = 0 37 | self._total_size = 1 38 | self._err_msg = [] 39 | 40 | def _error_msg(self, msg): 41 | """显示错误信息, 后台模式时保存信息而不显示""" 42 | self._err_msg.append(msg) 43 | 44 | def set_task_id(self, pid): 45 | """设置任务 id""" 46 | self._pid = pid 47 | 48 | def get_task_id(self): 49 | """获取当前任务 id""" 50 | return self._pid 51 | 52 | def get_task_type(self): 53 | """获取当前任务类型""" 54 | return self._task_type 55 | 56 | def get_process(self) -> (int, int): 57 | """获取下载进度""" 58 | return self._now_size, self._total_size 59 | 60 | def get_cmd_info(self): 61 | """获取命令行的信息""" 62 | return self._down_args, self._f_path 63 | 64 | def get_err_msg(self) -> list: 65 | """获取后台下载时保存的错误信息""" 66 | return self._err_msg 67 | 68 | def set_url(self, url): 69 | """设置 URL 下载任务""" 70 | if is_file_url(url): # 如果是文件 71 | self._down_args = url 72 | self._down_type = DownType.FILE_URL 73 | elif is_folder_url(url): 74 | self._down_args = url 75 | self._down_type = DownType.FOLDER_URL 76 | else: 77 | self._down_type = DownType.INVALID_URL 78 | 79 | def set_fid(self, fid, is_file=True, f_path=None): 80 | """设置 id 下载任务""" 81 | self._down_args = fid 82 | self._f_path = f_path # 文件(夹)名在网盘的路径 83 | self._down_type = DownType.FILE_ID if is_file else DownType.FOLDER_ID 84 | 85 | def _show_progress(self, file_name, total_size, now_size): 86 | """更新下载进度的回调函数""" 87 | self._total_size = total_size 88 | self._now_size = now_size 89 | 90 | def _show_down_failed(self, code, file): 91 | """文件下载失败时的回调函数""" 92 | if hasattr(file, 'url'): 93 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, URL: {file.url}") 94 | else: 95 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, ID: {file.id}") 96 | 97 | def run(self) -> None: 98 | if self._down_type == DownType.INVALID_URL: 99 | self._error_msg('(。>︿<) 该分享链接无效') 100 | 101 | elif self._down_type == DownType.FILE_URL: 102 | code = self._disk.down_file_by_url(self._down_args, '', self._save_path, callback=self._show_progress) 103 | if code == LanZouCloud.LACK_PASSWORD: 104 | pwd = input('输入该文件的提取码 : ') or '' 105 | code2 = self._disk.down_file_by_url(self._down_args, str(pwd), self._save_path, 106 | callback=self._show_progress) 107 | if code2 != LanZouCloud.SUCCESS: 108 | self._error_msg(f"文件下载失败: {why_error(code2)} -> {self._down_args}") 109 | elif code != LanZouCloud.SUCCESS: 110 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._down_args}") 111 | 112 | elif self._down_type == DownType.FOLDER_URL: 113 | code = self._disk.down_dir_by_url(self._down_args, '', self._save_path, callback=self._show_progress, 114 | recursive=True, mkdir=True, failed_callback=self._show_down_failed) 115 | if code == LanZouCloud.LACK_PASSWORD: 116 | pwd = input('输入该文件夹的提取码 : ') or '' 117 | code2 = self._disk.down_dir_by_url(self._down_args, str(pwd), self._save_path, 118 | callback=self._show_progress, recursive=True, 119 | mkdir=True, failed_callback=self._show_down_failed) 120 | if code2 != LanZouCloud.SUCCESS: 121 | self._error_msg(f"文件夹下载失败: {why_error(code2)} -> {self._down_args}") 122 | elif code != LanZouCloud.SUCCESS: 123 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._down_args}") 124 | 125 | elif self._down_type == DownType.FILE_ID: 126 | code = self._disk.down_file_by_id(self._down_args, self._save_path, callback=self._show_progress) 127 | if code != LanZouCloud.SUCCESS: 128 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._f_path}") 129 | 130 | elif self._down_type == DownType.FOLDER_ID: 131 | code = self._disk.down_dir_by_id(self._down_args, self._save_path, callback=self._show_progress, 132 | mkdir=True, failed_callback=self._show_down_failed, recursive=True) 133 | if code != LanZouCloud.SUCCESS: 134 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._f_path} ") 135 | 136 | 137 | class UploadType(Enum): 138 | """上传类型枚举类""" 139 | FILE = 0 140 | FOLDER = 1 141 | 142 | 143 | class Uploader(Thread): 144 | 145 | def __init__(self, disk: LanZouCloud): 146 | super(Uploader, self).__init__() 147 | self._task_type = TaskType.UPLOAD 148 | self._disk = disk 149 | self._pid = -1 150 | self._up_path = None 151 | self._up_type = None 152 | self._folder_id = -1 153 | self._folder_name = '' 154 | self._now_size = 0 155 | self._total_size = 1 156 | self._err_msg = [] 157 | self._default_file_pwd = config.default_file_pwd 158 | self._default_dir_pwd = config.default_dir_pwd 159 | self._uploaded_handler = None 160 | 161 | def _error_msg(self, msg): 162 | self._err_msg.append(msg) 163 | 164 | def set_task_id(self, pid): 165 | self._pid = pid 166 | 167 | def get_task_id(self): 168 | return self._pid 169 | 170 | def get_task_type(self): 171 | return self._task_type 172 | 173 | def get_process(self) -> (int, int): 174 | return self._now_size, self._total_size 175 | 176 | def get_cmd_info(self): 177 | return self._up_path, self._folder_name 178 | 179 | def get_err_msg(self) -> list: 180 | return self._err_msg 181 | 182 | def set_upload_path(self, path, is_file=True): 183 | """设置上传路径信息""" 184 | self._up_path = path 185 | self._up_type = UploadType.FILE if is_file else UploadType.FOLDER 186 | 187 | def set_uploaded_handler(self, handler): 188 | """设置上传完成后的回调函数""" 189 | if handler is not None: 190 | self._uploaded_handler = handler 191 | 192 | def set_target(self, folder_id=-1, folder_name=''): 193 | """设置网盘保存文件夹信息""" 194 | self._folder_id = folder_id 195 | self._folder_name = folder_name 196 | 197 | def _show_progress(self, file_name, total_size, now_size): 198 | self._total_size = total_size 199 | self._now_size = now_size 200 | 201 | def _show_upload_failed(self, code, filename): 202 | """文件下载失败时的回调函数""" 203 | self._error_msg(f"上传失败: {why_error(code)} -> {filename}") 204 | 205 | def _after_uploaded(self, fid, is_file): 206 | """上传完成自动设置提取码, 如果有其它回调函数就调用""" 207 | if is_file: 208 | self._disk.set_passwd(fid, self._default_file_pwd, is_file=True) 209 | else: 210 | self._disk.set_passwd(fid, self._default_dir_pwd, is_file=False) 211 | 212 | if self._uploaded_handler is not None: 213 | self._uploaded_handler(fid, is_file) 214 | 215 | def run(self) -> None: 216 | if self._up_type == UploadType.FILE: 217 | code = self._disk.upload_file(self._up_path, self._folder_id, callback=self._show_progress, 218 | uploaded_handler=self._after_uploaded) 219 | if code != LanZouCloud.SUCCESS: 220 | self._error_msg(f"文件上传失败: {why_error(code)} -> {self._up_path}") 221 | 222 | elif self._up_type == UploadType.FOLDER: 223 | code = self._disk.upload_dir(self._up_path, self._folder_id, callback=self._show_progress, 224 | failed_callback=self._show_upload_failed, 225 | uploaded_handler=self._uploaded_handler) 226 | if code != LanZouCloud.SUCCESS: 227 | self._error_msg(f"文件夹上传失败: {why_error(code)} -> {self._up_path}") 228 | -------------------------------------------------------------------------------- /lanzou/api/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | API 处理网页数据、数据切片时使用的工具 3 | """ 4 | 5 | import logging 6 | import os 7 | import pickle 8 | import re 9 | from datetime import timedelta, datetime 10 | from random import uniform, choices, sample, shuffle, choice 11 | 12 | import requests 13 | 14 | __all__ = ['logger', 'remove_notes', 'name_format', 'time_format', 'is_name_valid', 'is_file_url', 15 | 'is_folder_url', 'big_file_split', 'un_serialize', 'let_me_upload', 'auto_rename', 'calc_acw_sc__v2'] 16 | 17 | # 调试日志设置 18 | logger = logging.getLogger('lanzou') 19 | logger.setLevel(logging.ERROR) 20 | formatter = logging.Formatter( 21 | fmt="%(asctime)s [line:%(lineno)d] %(funcName)s %(levelname)s - %(message)s", 22 | datefmt="%Y-%m-%d %H:%M:%S") 23 | console = logging.StreamHandler() 24 | console.setFormatter(formatter) 25 | logger.addHandler(console) 26 | 27 | headers = { 28 | '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', 29 | # 'Referer': 'https://pan.lanzous.com', # 可以没有 30 | 'Accept-Language': 'zh-CN,zh;q=0.9', 31 | } 32 | 33 | 34 | def remove_notes(html: str) -> str: 35 | """删除网页的注释""" 36 | # 去掉 html 里面的 // 和 注释,防止干扰正则匹配提取数据 37 | # 蓝奏云的前端程序员喜欢改完代码就把原来的代码注释掉,就直接推到生产环境了 =_= 38 | html = re.sub(r'|\s+//\s*.+', '', html) # html 注释 39 | html = re.sub(r'(.+?[,;])\s*//.+', r'\1', html) # js 注释 40 | return html 41 | 42 | 43 | def name_format(name: str) -> str: 44 | """去除非法字符""" 45 | name = name.replace(u'\xa0', ' ').replace(u'\u3000', ' ').replace(' ', ' ') # 去除其它字符集的空白符,去除重复空白字符 46 | return re.sub(r'[$%^!*<>)(+=`\'\"/:;,?]', '', name) 47 | 48 | 49 | def time_format(time_str: str) -> str: 50 | """输出格式化时间 %Y-%m-%d""" 51 | if '秒前' in time_str or '分钟前' in time_str or '小时前' in time_str: 52 | return datetime.today().strftime('%Y-%m-%d') 53 | elif '昨天' in time_str: 54 | return (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d') 55 | elif '前天' in time_str: 56 | return (datetime.today() - timedelta(days=2)).strftime('%Y-%m-%d') 57 | elif '天前' in time_str: 58 | days = time_str.replace(' 天前', '') 59 | return (datetime.today() - timedelta(days=int(days))).strftime('%Y-%m-%d') 60 | else: 61 | return time_str 62 | 63 | 64 | def is_name_valid(filename: str) -> bool: 65 | """检查文件名是否允许上传""" 66 | 67 | valid_suffix_list = ( 68 | 'ppt', 'xapk', 'ke', 'azw', 'cpk', 'gho', 'dwg', 'db', 'docx', 'deb', 'e', 'ttf', 'xls', 'bat', 69 | 'crx', 'rpm', 'txf', 'pdf', 'apk', 'ipa', 'txt', 'mobi', 'osk', 'dmg', 'rp', 'osz', 'jar', 70 | 'ttc', 'z', 'w3x', 'xlsx', 'cetrainer', 'ct', 'rar', 'mp3', 'pptx', 'mobileconfig', 'epub', 71 | 'imazingapp', 'doc', 'iso', 'img', 'appimage', '7z', 'rplib', 'lolgezi', 'exe', 'azw3', 'zip', 72 | 'conf', 'tar', 'dll', 'flac', 'xpa', 'lua', 'cad', 'hwt', 'accdb', 'ce', 73 | 'xmind', 'enc', 'bds', 'bdi', 'ssf', 'it', 'gz' 74 | ) 75 | 76 | return filename.split('.')[-1].lower() in valid_suffix_list 77 | 78 | 79 | def is_file_url(share_url: str) -> bool: 80 | """判断是否为文件的分享链接""" 81 | base_pat = r'https?://[a-zA-Z0-9-]*?\.?lanzou[a-z].com/.+' # 子域名可个性化设置或者不存在 82 | user_pat = r'https?://[a-zA-Z0-9-]*?\.?lanzou[a-z].com/i[a-zA-Z0-9]{5,}(\?webpage=[a-zA-Z0-9]+?)?/?' # 普通用户 URL 规则 83 | if not re.fullmatch(base_pat, share_url): 84 | return False 85 | if re.fullmatch(user_pat, share_url): 86 | return True 87 | # VIP 用户的 URL 很随意 88 | try: 89 | html = requests.get(share_url, headers=headers).text 90 | html = remove_notes(html) 91 | return True if re.search(r'class="fileinfo"|id="file"|文件描述', html) else False 92 | except (requests.RequestException, Exception): 93 | return False 94 | 95 | 96 | def is_folder_url(share_url: str) -> bool: 97 | """判断是否为文件夹的分享链接""" 98 | base_pat = r'https?://[a-zA-Z0-9-]*?\.?lanzou[a-z].com/.+' 99 | user_pat = r'https?://[a-zA-Z0-9-]*?\.?lanzou[a-z].com/(/s/)?b[a-zA-Z0-9]{7,}/?' 100 | if not re.fullmatch(base_pat, share_url): 101 | return False 102 | if re.fullmatch(user_pat, share_url): 103 | return True 104 | # VIP 用户的 URL 很随意 105 | try: 106 | html = requests.get(share_url, headers=headers).text 107 | html = remove_notes(html) 108 | return True if re.search(r'id="infos"', html) else False 109 | except (requests.RequestException, Exception): 110 | return False 111 | 112 | 113 | def un_serialize(data: bytes): 114 | """反序列化文件信息数据""" 115 | # https://github.com/zaxtyson/LanZouCloud-API/issues/65 116 | is_right_format = False 117 | if data.startswith(b"\x80\x04") and data.endswith(b"u."): 118 | is_right_format = True 119 | if data.startswith(b"\x80\x03") and data.endswith(b"u."): 120 | is_right_format = True 121 | 122 | if not is_right_format: 123 | return None 124 | try: 125 | ret = pickle.loads(data) 126 | if not isinstance(ret, dict): 127 | return None 128 | return ret 129 | except Exception: # 这里可能会丢奇怪的异常 130 | return None 131 | 132 | 133 | def big_file_split(file_path: str, max_size: int = 100, start_byte: int = 0) -> (int, str): 134 | """将大文件拆分为大小、格式随机的数据块, 可指定文件起始字节位置(用于续传) 135 | :return 数据块文件的大小和绝对路径 136 | """ 137 | file_name = os.path.basename(file_path) 138 | file_size = os.path.getsize(file_path) 139 | tmp_dir = os.path.dirname(file_path) + os.sep + '__' + '.'.join(file_name.split('.')[:-1]) 140 | 141 | if not os.path.exists(tmp_dir): 142 | os.makedirs(tmp_dir) 143 | 144 | def get_random_size() -> int: 145 | """按权重生成一个不超过 max_size 的文件大小""" 146 | reduce_size = choices([uniform(0, 20), uniform(20, 30), uniform(30, 60), uniform(60, 80)], weights=[2, 5, 2, 1]) 147 | return round((max_size - reduce_size[0]) * 1048576) 148 | 149 | def get_random_name() -> str: 150 | """生成一个随机文件名""" 151 | # 这些格式的文件一般都比较大且不容易触发下载检测 152 | suffix_list = ('zip', 'rar', 'apk', 'ipa', 'exe', 'pdf', '7z', 'tar', 'deb', 'dmg', 'rpm', 'flac') 153 | name = list(file_name.replace('.', '').replace(' ', '')) 154 | name = name + sample('abcdefghijklmnopqrstuvwxyz', 3) + sample('1234567890', 2) 155 | shuffle(name) # 打乱顺序 156 | name = ''.join(name) + '.' + choice(suffix_list) 157 | return name_format(name) # 确保随机名合法 158 | 159 | with open(file_path, 'rb') as big_file: 160 | big_file.seek(start_byte) 161 | left_size = file_size - start_byte # 大文件剩余大小 162 | random_size = get_random_size() 163 | tmp_file_size = random_size if left_size > random_size else left_size 164 | tmp_file_path = tmp_dir + os.sep + get_random_name() 165 | 166 | chunk_size = 524288 # 512KB 167 | left_read_size = tmp_file_size 168 | with open(tmp_file_path, 'wb') as small_file: 169 | while left_read_size > 0: 170 | if left_read_size < chunk_size: # 不足读取一次 171 | small_file.write(big_file.read(left_read_size)) 172 | break 173 | # 一次读取一块,防止一次性读取占用内存 174 | small_file.write(big_file.read(chunk_size)) 175 | left_read_size -= chunk_size 176 | 177 | return tmp_file_size, tmp_file_path 178 | 179 | 180 | def let_me_upload(file_path): 181 | """允许文件上传""" 182 | file_size = os.path.getsize(file_path) / 1024 / 1024 # MB 183 | file_name = os.path.basename(file_path) 184 | 185 | big_file_suffix = ['zip', 'rar', 'apk', 'ipa', 'exe', 'pdf', '7z', 'tar', 'deb', 'dmg', 'rpm', 'flac'] 186 | small_file_suffix = big_file_suffix + ['doc', 'epub', 'mobi', 'mp3', 'ppt', 'pptx'] 187 | big_file_suffix = choice(big_file_suffix) 188 | small_file_suffix = choice(small_file_suffix) 189 | suffix = small_file_suffix if file_size < 30 else big_file_suffix 190 | new_file_path = '.'.join(file_path.split('.')[:-1]) + '.' + suffix 191 | 192 | with open(new_file_path, 'wb') as out_f: 193 | # 写入原始文件数据 194 | with open(file_path, 'rb') as in_f: 195 | chunk = in_f.read(4096) 196 | while chunk: 197 | out_f.write(chunk) 198 | chunk = in_f.read(4096) 199 | # 构建文件 "报尾" 保存真实文件名,大小 512 字节 200 | # 追加数据到文件尾部,并不会影响文件的使用,无需修改即可分享给其他人使用,自己下载时则会去除,确保数据无误 201 | # protocol=4(py3.8默认), 序列化后空字典占 42 字节 202 | padding = 512 - len(file_name.encode('utf-8')) - 42 203 | data = {'name': file_name, 'padding': b'\x00' * padding} 204 | data = pickle.dumps(data, protocol=4) 205 | out_f.write(data) 206 | return new_file_path 207 | 208 | 209 | def auto_rename(file_path) -> str: 210 | """如果文件存在,则给文件名添加序号""" 211 | if not os.path.exists(file_path): 212 | return file_path 213 | fpath, fname = os.path.split(file_path) 214 | fname_no_ext, ext = os.path.splitext(fname) 215 | flist = [f for f in os.listdir(fpath) if re.fullmatch(rf"{fname_no_ext}\(?\d*\)?{ext}", f)] 216 | count = 1 217 | while f"{fname_no_ext}({count}){ext}" in flist: 218 | count += 1 219 | return fpath + os.sep + fname_no_ext + '(' + str(count) + ')' + ext 220 | 221 | 222 | def calc_acw_sc__v2(html_text: str) -> str: 223 | arg1 = re.search(r"arg1='([0-9A-Z]+)'", html_text) 224 | arg1 = arg1.group(1) if arg1 else "" 225 | acw_sc__v2 = hex_xor(unsbox(arg1), "3000176000856006061501533003690027800375") 226 | return acw_sc__v2 227 | 228 | 229 | # 参考自 https://zhuanlan.zhihu.com/p/228507547 230 | def unsbox(str_arg): 231 | v1 = [15, 35, 29, 24, 33, 16, 1, 38, 10, 9, 19, 31, 40, 27, 22, 23, 25, 13, 6, 11, 39, 18, 20, 8, 14, 21, 32, 26, 2, 232 | 30, 7, 4, 17, 5, 3, 28, 34, 37, 12, 36] 233 | v2 = ["" for _ in v1] 234 | for idx in range(0, len(str_arg)): 235 | v3 = str_arg[idx] 236 | for idx2 in range(len(v1)): 237 | if v1[idx2] == idx + 1: 238 | v2[idx2] = v3 239 | 240 | res = ''.join(v2) 241 | return res 242 | 243 | 244 | def hex_xor(str_arg, args): 245 | res = '' 246 | for idx in range(0, min(len(str_arg), len(args)), 2): 247 | v1 = int(str_arg[idx:idx + 2], 16) 248 | v2 = int(args[idx:idx + 2], 16) 249 | v3 = format(v1 ^ v2, 'x') 250 | if len(v3) == 1: 251 | v3 = '0' + v3 252 | res += v3 253 | 254 | return res 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

- 蓝奏云CMD -

6 | 7 |

8 | 9 | 10 | 11 |

12 | 13 | # 界面 14 | 15 | > 仅供参考, 请以实物为准😁 (这是KDE Plasma下的截图) 16 | 17 | ![cmd.png](https://upload.cc/i1/2020/04/04/W8GKsr.png) 18 | 19 | # 说明 20 | 21 | - 本项目基于 [LanZouCloud-API](https://github.com/zaxtyson/LanZouCloud-API), 与 API 同步更新, 22 | 详细介绍请移步 [Wiki](https://github.com/zaxtyson/LanZouCloud-CMD/wiki) 23 | - 如需要解除官方限制, 请 fork 本项目并修改部分代码 24 | - 关注本页面以获取更新, 如果有问题或者建议, 欢迎提 issue 25 | - 维护不易, 如果喜欢本项目, 请给一个 star 🌟️ 26 | 27 | # 下载 28 | 29 | > ❤ 感谢 [rachpt](https://github.com/rachpt/lanzou-gui) 开发和维护 [GUI 版本](https://github.com/rachpt/lanzou-gui/wiki) 30 | 31 | ## Linux/MacOS 32 | 33 | ``` 34 | # 从原始仓库克隆 35 | git clone https://github.com/zaxtyson/LanZouCloud-CMD.git 36 | 37 | # 或者使用镜像加速 38 | git clone https://github.91chi.fun/https://github.com/zaxtyson/LanZouCloud-CMD.git 39 | 40 | # 进入项目目录 41 | cd LanZouCloud-CMD 42 | 43 | # 安装依赖, 要求 Python >= 3.8 44 | python3 -m pip install -r requirements.txt 45 | 46 | # 运行 47 | python3 main.py 48 | ``` 49 | 50 | ## Windows 51 | 52 | - 从[蓝奏云](https://zaxtyson.lanzouf.com/b0f14h1od)下载安装包/解压包 53 | - 或者在本项目的 [`releases`](https://github.com/zaxtyson/LanZouCloud-CMD/releases) 板块下载 54 | 55 | 56 | # 命令速览 57 | 58 | > 路径和文件名支持 TAB 补全 59 | 60 | |命令 | 描述 | 61 | |-------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 62 | |help | 查看帮助文档 | 63 | |update | 检测更新 | 64 | |login | 使用 Cookie 登录[[1]](https://github.com/zaxtyson/LanZouCloud-API/issues/21#issuecomment-632964409) [[2]](https://github.com/zaxtyson/LanZouCloud-CMD#v235-%E6%9B%B4%E6%96%B0%E8%AF%B4%E6%98%8E) | 65 | |logout | 注销当前账号 | 66 | |refresh | 刷新当前目录 | 67 | |clear | 清空屏幕 | 68 | |ls | 列出文件与目录 | 69 | |mkdir `文件夹` | 创建文件夹(路径最大深度 4 级) | 70 | |rm `文件(夹)名` | 将文件(夹)放入回收站 | 71 | |cd `路径` | 切换工作目录(`..`表示上级路径, `-`表示上一次路径) 72 | |mv `文件(夹)名` | 移动文件(夹) | 73 | |rename `文件(夹)名` | 重命名(普通用户无法修改文件名) | 74 | |desc `文件(夹)名` | 设置文件(夹)描述信息 | 75 | |passwd `文件(夹)名` | 设置文件(夹)提取码 | 76 | |setpath | 修改下载路径(默认 ~/Download) | 77 | |setsize | 修改单文件大小限制 | 78 | |setpasswd | 设置文件(夹)默认提取码 | 79 | |setdelay | 设置数据块上传延时,防止被封号(突破官方限制时使用) | 80 | |upload `文件(夹)路径` | 上传文件(夹) | 81 | |down `文件(夹)名/分享链接` | 下载文件(夹) | 82 | |share `文件/文件夹` | 查看文件(夹)分享信息 | 83 | |export `文件夹名` | 导出文件夹下的文件信息到CSV文件 | 84 | |xghost | 清理"幽灵"文件夹(网盘中看不见,移动时会显示) | 85 | |rmode | 方便视障用户使用屏幕阅读器阅读 | 86 | |jobs | 查看后台上传/下载任务 | 87 | |jobs `任务ID` | 查看任务详细信息(错误原因) | 88 | |cdrec | 进入回收站 | 89 | |[cdrec] ls | 显示回收站文件 | 90 | |[cdrec] rec `文件(夹)名` | 恢复文件(夹) | 91 | |[cdrec] clean | 清空回收站 | 92 | |[cdrec] cd .. | 退出回收站 | 93 | |bye | 退出程序 | 94 | 95 | > 关于 Cookie 登录 96 | 97 | - 由于 Web 和 App 端均被加上了滑动验证, CMD 不便绕过, 故使用 Cookie 登录 98 | - 一般情况下, 程序能够自动读取浏览器的 Cookie, 完成登录, 如果有问题可手工输入 99 | - Cookie 内容见浏览器地址栏前的🔒 (Chrome/Microsoft Edge) 100 | - `woozooo.com` -> `Cookie` -> `ylogin` -> `内容` 101 | - `pc.woozooo.com` -> `Cookie` -> `phpdisk_info` -> `内容` 102 | - 需要注意的是 `phpdisk_info` 字段是很长的, 我们有许多伙计复制的时候**没有复制完整**而导致登录失败 103 | - 所以建议按 `F12` 进入调试页面, 找到 `应用程序` -> `存储` -> `Cookie` 可以很方便的找到 Cookie 104 | 105 | # 嵌入第三方终端 106 | 107 | ## Windows Terminal 108 | 109 | 如果伙计们觉得 Windows 的 CMD 窗口太丑, 可以把蓝奏云控制台加入新版 110 | [Terminal](https://docs.microsoft.com/zh-cn/windows/terminal/) 111 | 的选项卡中(目前已内置到Windows11且成为默认Terminal) 112 | 113 | - 打开 `设置` -> `添加新配置文件`, 如果是安装版且未修改安装路径, 直接复制以下配置即可: 114 | 115 | ``` 116 | [名称] 117 | 蓝奏云控制台 118 | 119 | [命令行] 120 | %appdata%\..\Local\Programs\lanzou\lanzou-cmd.exe 121 | 122 | [启动目录] 123 | %appdata%\..\Local\Programs\lanzou 124 | 125 | [图标] 126 | %appdata%\..\Local\Programs\lanzou\logo.ico 127 | ``` 128 | 129 | 如果是 zip 版本, 请将路径替换为解压时的位置 130 | 131 | ## Tabby 132 | 133 | 除了 Windows Terminal, 还可以试试 [Tabby](https://github.com/Eugeny/tabby), 134 | 非常好用, 配置方法类似 135 | 136 | 137 | # 更新日志 138 | 139 | ## `v2.6.8` 140 | 141 | - 修复分享链接带有 `webpage` 参数时无法下载的问题[#81](https://github.com/zaxtyson/LanZouCloud-API/issues/81) 142 | - 修复访问 GithubAPI 调用次数超限导致检查更新时崩溃的问题 143 | 144 | ## `v2.6.7` 145 | 146 | - 修复分享链接带有 `webpage` 参数时无法下载的问题[#74](https://github.com/zaxtyson/LanZouCloud-API/issues/74) 147 | - 自动登录功能支持 Win11 下的 Microsoft Edge 148 | 149 | ## `v2.6.6` 150 | 151 | - 修复 pickle 库反序列化导致内存溢出的问题[#65](https://github.com/zaxtyson/LanZouCloud-API/issues/65) 152 | - 更换为最新域名, 防止解析失败[#68](https://github.com/zaxtyson/LanZouCloud-API/pull/68) 153 | 154 | ## `v2.6.5` 155 | 156 | - 修复蓝奏云主域名解析异常的问题[#59](https://github.com/zaxtyson/LanZouCloud-API/issues/59) [#60](https://github.com/zaxtyson/LanZouCloud-API/pull/60) 157 | - 修复某些文件夹信息获取失败的问题[#58](https://github.com/zaxtyson/LanZouCloud-API/pull/58) 158 | - 修复下载页的 Cookie 验证问题[#55](https://github.com/zaxtyson/LanZouCloud-API/pull/55) 159 | 160 | ## `v2.6.3` 161 | 162 | - 修复文件后缀非小写导致的误判问题[#92](https://github.com/rachpt/lanzou-gui/issues/92) 163 | 164 | ## `v2.6.2` 165 | 166 | - 支持登录时自动读取浏览器 Cookie[#59](https://github.com/zaxtyson/LanZouCloud-CMD/issues/59) 167 | - 默认下载路径更改为 `用户家目录下的 Downloads 文件夹` 168 | 169 | ## `v2.6.1` 170 | 171 | - 修复下载某些 txt 文件失败的问题[#53](https://github.com/zaxtyson/LanZouCloud-API/issues/53) 172 | - 上传完成自动刷新工作目录, 无需手动刷新 173 | - 修复文件(夹)提取码标识符号失效的问题 174 | - `jobs`, `jobs ID` 命令输出美化了一点 175 | 176 | ## `v2.6.0` 177 | 178 | - 修复无法上传文件的问题 [#52](https://github.com/zaxtyson/LanZouCloud-API/pull/52) 179 | - 新增 11 种允许上传的文件格式[#90](https://github.com/rachpt/lanzou-gui/issues/90) 180 | - 修复会员自定义文件夹 URL 识别错误的问题[#84](https://github.com/rachpt/lanzou-gui/issues/84) 181 | 182 | ## `v2.5.7` 183 | 184 | - 修复 VIP 用户分享的递归文件夹无法下载的问题[#49](https://github.com/zaxtyson/LanZouCloud-CMD/issues/49) 185 | - 修复用户描述中带字符串`请输入密码`而文件没有设置提取码导致误判的问题 186 | - 下载文件夹时会递归下载并自动创建对应文件夹 187 | 188 | ## `v2.5.5` 189 | 190 | - 修复下载两个同名文件时断点续传功能异常的问题[#35](https://github.com/zaxtyson/LanZouCloud-API/issues/35#issue-668534695) 191 | - 下载同名文件时自动添加序号以便区分 192 | - 修复蓝奏云将文件名敏感词(如[小姐](https://zaxtyson.lanzous.com/ic59zpg))替换为`*`导致下载文件崩溃的问题 193 | - 修复文件大小中出现 `,` 导致无法完整匹配文件大小的问题 194 | 195 | ## `v2.5.4` 196 | - 修复下载某些文件时验证方式变化的问题 197 | - 修复 export 导出时的编码问题(Excel乱码: 数据->从文本/CSV导入->UTF-8编码) 198 | 199 | ## `v2.5.3` 200 | - 修复新出现的 URL 包含大写字符无法匹配的问题 201 | 202 | ## `v2.5.2` 203 | - 修复子域名变化导致的异常 204 | - `share` 命令不再显示文件夹下的文件信息 205 | - 新增 `export` 命令, 支持批量导出文件夹内的文件信息(csv文件) 206 | 207 | ## `v2.5.0` 208 | - 遵守官方限制, 不再支持大文件上传和文件名伪装功能(之前上传的文件仍可以正常下载) 209 | - 登录接口被限制, 使用 Cookie 登录, 参见 `v2.3.5` 更新日志 210 | 211 | ## `v2.4.5` 212 | - 修复无法处理蓝奏云自定义域名的问题 213 | - 修复新用户执行与创建文件夹相关命令时崩溃的问题 214 | - 新增 `setpasswd` 命令设置文件(夹)默认提取码 215 | - 新增 `setdelay` 命令设置大文件数据块上传延时,减小被封的可能性 216 | - 出于 PD 事件的影响,这将是本项目最后一次更新 217 | - CMD 版本将去除大文件上传功能,仅保留蓝奏云的基本功能 218 | - API 保留了相关功能,有能力者请自行开发,但是您需要承担由此带来的风险 219 | - **本项目的代码会在一段时间后删除**,在此之前,请保存好您的网盘的大文件 220 | 221 | ## `v2.4.4` 222 | - `ls` 命令显示文件下载次数 223 | - 修复 VIP 用户分享链接无法处理的问题 224 | - 修复下载时可能出现的 Read time out 异常 225 | - 修复上传大文件自动创建文件夹名包含 `mkdir` 字符串后缀的问题(这不是feature,只是测试时无意中写到代码里了-_-) 226 | - Windows 发行版使用 Inno Setup 封装,直接安装,方便更新 227 | 228 | ## `v2.4.3` 229 | - 上传/下载支持断点续传,大文件续传使用 `filename.record` 文件保存进度,请不要手动修改和删除 230 | - 新增 `jobs` 命令查看后台任务, 支持提交多个上传/下载任务,使用 `jobs PID` 查看任务详情 231 | - 新增 `xghost` 命令用于清理网盘中的"幽灵文件夹"(不在网盘和回收站显示的文件夹,移动文件时可以看见,文件移进去就丢失) 232 | - 遇到下载验证码时自动打开图片,要求用户输入验证码 233 | - 修复了其它的细节问题 234 | 235 | ## `2.4.2` 236 | - 紧急修复了蓝奏云网页端变化导致无法显示文件夹的 Bug 237 | 238 | ## `v2.4.1` 239 | - 修复使用 URL 下载大文件失败的问题 240 | - 修复上传小文件时没有去除非法字符的问题 241 | - 新增 `rmode` 命令,以适宜屏幕阅读器阅读的方式显示 242 | 243 | ## `v2.4.0` 244 | - 放弃分段压缩,使用更复杂的方式上传大文件。分段数据文件名、文件大小、文件后缀随机,下载时自动处理。 245 | - 放弃使用修改文件名的方式绕过上传格式限制。上传的文件末尾被添加了 512 字节的信息,储存真实文件名, 246 | 下载时自动检测并截断,不会影响文件 hash。一般情况下,不截断此信息不影响文件的使用,但纯文本类文件会受影响(比如代码文件), 247 | 建议压缩后上传。 248 | - 现在可以在网盘不同路径下创建同名文件夹,不再添加 `_` 区分,移动文件时支持绝对路径补全。 249 | - 上传/下载失败会立即提醒并显示原因,不需要等待全部任务完成。 250 | - 回收站稍微好看了点~ 251 | 252 | ## `v2.3.5` 更新说明 253 | - 修复回收站文件夹中文件名过长,导致后缀丢失,程序闪退的问题 [#14](https://github.com/zaxtyson/LanZouCloud-CMD/issues/14) 254 | - 修复官方启用滑动验证导致无法登录的问题 [#15](https://github.com/zaxtyson/LanZouCloud-CMD/issues/15) 255 | - 新增 `clogin` 命令支持使用 `cookie` 登录(防止某天 `login` 完全失效) 256 | - Cookie 内容见浏览器地址栏前的🔒 (Chrome): 257 | - `woozooo.com -> Cookie -> ylogin` 258 | - `pc.woozooo.com -> Cookie -> phpdisk_info` 259 | - 因为使用 `Python3.8.1 X64` 打包,导致程序大了一圈😭,您可以使用 `Pyinstaller` 自行打包 260 | 261 | ## `v2.3.4` 更新说明 262 | - 新增 `update` 命令检查更新(每次启动会检查一次) 263 | - 解除了官方对上传分卷文件的限制 [#11](https://github.com/zaxtyson/LanZouCloud-CMD/issues/11) [#12](https://github.com/zaxtyson/LanZouCloud-CMD/issues/12) 264 | - `rename` 命令支持会员用户修改文件名 [#9](https://github.com/zaxtyson/LanZouCloud-CMD/issues/9) 265 | - 新增 `setsize` 命令支持会员用户修改分卷大小 [#9](https://github.com/zaxtyson/LanZouCloud-CMD/issues/9) 266 | - `mv` 命令支持移动文件夹(不含子文件夹) 267 | - 支持 `cd /` 返回根目录, `cd -` 返回上一次工作目录 [#8](https://github.com/zaxtyson/LanZouCloud-CMD/issues/8) 268 | - 修复了某些特殊情况下回收站崩溃的问题 269 | - `ls` 命令在文件描述为中英文混合时能够正确对齐 [#8](https://github.com/zaxtyson/LanZouCloud-CMD/issues/8) 270 | - 下载时可以使用 `Ctrl + C` 强行中断 271 | - 修复文件上传时间的错误 272 | 273 | 274 | ## `v2.3.3` 更新说明 275 | - 修复上传超过 1GB 的文件时,前 10 个分卷丢失的 Bug [#7](https://github.com/zaxtyson/LanZouCloud-CMD/issues/7) 276 | 277 | ## `v2.3.2` 更新说明 278 | - 修复了无法上传的 Bug 279 | - 解除了官方对文件名包含多个后缀的限制 280 | - 使用 cookie 登录,配置文件不再保存明文 281 | 282 | ## `v2.3.1` 更新说明 283 | - 界面焕然一新 284 | - 修复了一堆 BUG 285 | - 新增设置描述信息功能 286 | - 完善了回收站功能 287 | - 完善了移动文件功能 288 | 289 | ## `v2.2.1` 更新说明 290 | - 修复了文件(夹)无法下载的问题 [#4](https://github.com/zaxtyson/LanZouCloud-CMD/issues/4) 291 | - 修复了上传 rar 分卷文件被 ban 的问题 292 | - 修复了无后缀文件上传出错的问题 293 | - 修复了文件中空白字符导致上传和解压失败的问题 294 | 295 | ## `v2.1` 更新说明 296 | - 修复了蓝奏云分享链接格式变化导致无法获取直链的问题 297 | 298 | ## `v2.0`更新说明 299 | - 修复了登录 `formhash` 的错误 300 | - 增加了上传/下载的进度条 [#1](https://github.com/zaxtyson/LanZouCloud-CMD/issues/1) 301 | - 使用 RAR 分卷压缩代替文件分段 [#2](https://github.com/zaxtyson/LanZouCloud-CMD/issues/2) 302 | - 修复了连续上传大文件被ban的问题 [#3](https://github.com/zaxtyson/LanZouCloud-CMD/issues/3) 303 | - 增加了回收站功能 304 | - 取消了`种子文件`下载方式,自动识别分卷数据并解压 305 | - 增加了通过分享链接下载的功能 306 | -------------------------------------------------------------------------------- /lanzou/cmder/cmder.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from functools import wraps 3 | from sys import exit as exit_cmd 4 | from time import strftime, localtime 5 | from webbrowser import open_new_tab 6 | 7 | from lanzou.api.models import FileList, FolderList 8 | from lanzou.api.types import * 9 | from lanzou.cmder.browser_cookie import load_with_keys 10 | from lanzou.cmder.downloader import Downloader, Uploader 11 | from lanzou.cmder.manager import global_task_mgr 12 | from lanzou.cmder.recovery import Recovery 13 | from lanzou.cmder.utils import * 14 | 15 | 16 | def require_login(func): 17 | """登录检查装饰器""" 18 | 19 | @wraps(func) 20 | def wrapper(self, *args, **kwargs): 21 | if not self._is_login: 22 | error(f"请登录后再使用此命令: {wrapper.__name__}") 23 | else: 24 | func(self, *args, **kwargs) 25 | 26 | return wrapper 27 | 28 | 29 | class Commander: 30 | """蓝奏网盘命令行""" 31 | 32 | def __init__(self): 33 | self._prompt = '> ' 34 | self._disk = LanZouCloud() 35 | self._is_login = False 36 | self._task_mgr = global_task_mgr 37 | self._dir_list = FolderList() 38 | self._file_list = FileList() 39 | self._path_list = FolderList() 40 | self._parent_id = -1 41 | self._parent_name = '' 42 | self._work_name = '' 43 | self._work_id = -1 44 | self._last_work_id = -1 45 | self._reader_mode = config.reader_mode 46 | self._default_dir_pwd = config.default_dir_pwd 47 | self._disk.set_max_size(config.max_size) 48 | self._disk.set_upload_delay(config.upload_delay) 49 | 50 | if ignore_limit(): 51 | """Please take responsibility for your own actions""" 52 | self._disk.ignore_limits() 53 | 54 | @staticmethod 55 | def clear(): 56 | clear_screen() 57 | 58 | @staticmethod 59 | def help(): 60 | print_help() 61 | 62 | @staticmethod 63 | def update(): 64 | check_update() 65 | 66 | def bye(self): 67 | if self._task_mgr.has_alive_task(): 68 | info(f"有任务在后台运行, 退出请直接关闭窗口") 69 | else: 70 | exit_cmd(0) 71 | 72 | def rmode(self): 73 | """适用于屏幕阅读器用户的显示方式""" 74 | choice = input("以适宜屏幕阅读器的方式显示(y): ") 75 | if choice and choice.lower() == 'y': 76 | config.reader_mode = True 77 | self._reader_mode = True 78 | info("已启用 Reader Mode") 79 | else: 80 | config.reader_mode = False 81 | self._reader_mode = False 82 | info("已关闭 Reader Mode") 83 | 84 | @require_login 85 | def cdrec(self): 86 | """进入回收站模式""" 87 | rec = Recovery(self._disk) 88 | rec.run() 89 | self.refresh() 90 | 91 | @require_login 92 | def xghost(self): 93 | """扫描并删除幽灵文件夹""" 94 | choice = input("需要清理幽灵文件夹吗(y): ") 95 | if choice and choice.lower() == 'y': 96 | self._disk.clean_ghost_folders() 97 | info("清理已完成") 98 | else: 99 | info("清理操作已取消") 100 | 101 | @require_login 102 | def refresh(self, dir_id=None): 103 | """刷新当前文件夹和路径信息""" 104 | dir_id = self._work_id if dir_id is None else dir_id 105 | self._file_list = self._disk.get_file_list(dir_id) 106 | self._dir_list = self._disk.get_dir_list(dir_id) 107 | self._path_list = self._disk.get_full_path(dir_id) 108 | self._prompt = '/'.join(self._path_list.all_name) + ' > ' 109 | self._last_work_id = self._work_id 110 | self._work_name = self._path_list[-1].name 111 | self._work_id = self._path_list[-1].id 112 | if dir_id != -1: # 如果存在上级路径 113 | self._parent_name = self._path_list[-2].name 114 | self._parent_id = self._path_list[-2].id 115 | 116 | def login(self): 117 | """使用 cookie 登录""" 118 | if config.cookie and self._disk.login_by_cookie(config.cookie) == LanZouCloud.SUCCESS: 119 | self._is_login = True 120 | self.refresh() 121 | return 122 | 123 | auto = input("自动读取浏览器 Cookie 登录(y): ") or "y" 124 | if auto != "y": 125 | info("请手动设置 Cookie 内容:") 126 | ylogin = input("ylogin: ") or "" 127 | disk_info = input("phpdisk_info: ") or "" 128 | cookie = {"ylogin": str(ylogin), "phpdisk_info": disk_info} 129 | if not ylogin or not disk_info: 130 | error("请输入正确的 Cookie 信息") 131 | return 132 | else: 133 | cookie, browser = load_with_keys('.woozooo.com', ['ylogin', 'phpdisk_info']) 134 | if cookie: 135 | print() 136 | info(f"从 {browser} 读取用户 Cookie 成功") 137 | else: 138 | info("请在浏览器端登录账号") 139 | open_new_tab('https://pc.woozooo.com/') 140 | info("浏览器可能等待几秒才将数据写入磁盘, 请稍等") 141 | counter = 0 142 | while not cookie: 143 | sleep(1) 144 | counter += 1 145 | print('.', end='', flush=True) 146 | cookie, browser = load_with_keys('.woozooo.com', ['ylogin', 'phpdisk_info']) 147 | 148 | if cookie: 149 | print() 150 | info(f"从 {browser} 读取用户 Cookie 成功") 151 | break 152 | 153 | if counter == 10: # 读取超时 154 | ctn = input("\n暂未读取到浏览器数据, 继续扫描(y) :") or "y" 155 | if ctn == "y": 156 | counter = 0 157 | continue 158 | else: 159 | error("自动读取 Cookie 失败") 160 | return 161 | 162 | # 登录蓝奏云 163 | if self._disk.login_by_cookie(cookie) == LanZouCloud.SUCCESS: 164 | config.cookie = cookie 165 | self._is_login = True 166 | self.refresh() 167 | else: 168 | error("登录失败,请检查 Cookie 是否正确") 169 | 170 | @require_login 171 | def logout(self): 172 | """注销""" 173 | clear_screen() 174 | self._prompt = '> ' 175 | self._disk.logout() 176 | self._is_login = False 177 | self._file_list.clear() 178 | self._dir_list.clear() 179 | self._path_list = FolderList() 180 | self._parent_id = -1 181 | self._work_id = -1 182 | self._last_work_id = -1 183 | self._parent_name = '' 184 | self._work_name = '' 185 | 186 | config.cookie = None 187 | info("本地 Cookie 已清除") 188 | 189 | @require_login 190 | def ls(self): 191 | """列出文件(夹)""" 192 | if self._reader_mode: # 方便屏幕阅读器阅读 193 | for folder in self._dir_list: 194 | pwd_str = '有提取码' if folder.has_pwd else '' 195 | print(f"{folder.name}/ {folder.desc} {pwd_str}") 196 | for file in self._file_list: 197 | pwd_str = '有提取码' if file.has_pwd else '' 198 | print(f"{file.name} 大小:{file.size} 上传时间:{file.time} 下载次数:{file.downs} {pwd_str}") 199 | else: # 普通用户显示方式 200 | for folder in self._dir_list: 201 | pwd_str = '◆' if folder.has_pwd else '◇' 202 | print("#{0:<12}{1:<2}{2}{3}/".format( 203 | folder.id, pwd_str, text_align(folder.desc, 28), folder.name)) 204 | for file in self._file_list: 205 | pwd_str = '◆' if file.has_pwd else '◇' 206 | print("#{0:<12}{1:<2}{2:<12}{3:>8}{4:>6}↓ {5}".format( 207 | file.id, pwd_str, file.time, file.size, file.downs, file.name)) 208 | 209 | @require_login 210 | def cd(self, dir_name): 211 | """切换工作目录""" 212 | if not dir_name: 213 | info('cd .. 返回上级路径, cd - 返回上次路径, cd / 返回根目录') 214 | elif dir_name == '..': 215 | self.refresh(self._parent_id) 216 | elif dir_name == '/': 217 | self.refresh(-1) 218 | elif dir_name == '-': 219 | self.refresh(self._last_work_id) 220 | elif dir_name == '.': 221 | pass 222 | elif folder := self._dir_list.find_by_name(dir_name): 223 | self.refresh(folder.id) 224 | else: 225 | error(f'文件夹不存在: {dir_name}') 226 | 227 | @require_login 228 | def mkdir(self, name): 229 | """创建文件夹""" 230 | if self._dir_list.find_by_name(name): 231 | error(f'文件夹已存在: {name}') 232 | return None 233 | 234 | dir_id = self._disk.mkdir(self._work_id, name, '') 235 | if dir_id == LanZouCloud.MKDIR_ERROR: 236 | error(f'创建文件夹失败(深度最大 4 级)') 237 | return None 238 | # 创建成功,添加到文件夹列表,减少向服务器请求次数 239 | self._disk.set_passwd(dir_id, self._default_dir_pwd, is_file=False) 240 | self._dir_list.append(Folder(name, dir_id, bool(self._default_dir_pwd), '')) 241 | 242 | @require_login 243 | def rm(self, name): 244 | """删除文件(夹)""" 245 | if file := self._file_list.find_by_name(name): # 删除文件 246 | if self._disk.delete(file.id, True) == LanZouCloud.SUCCESS: 247 | self._file_list.pop_by_id(file.id) 248 | else: 249 | error(f'删除文件失败: {name}') 250 | elif folder := self._dir_list.find_by_name(name): # 删除文件夹 251 | if self._disk.delete(folder.id, False) == LanZouCloud.SUCCESS: 252 | self._dir_list.pop_by_id(folder.id) 253 | else: 254 | error(f'删除文件夹失败(存在子文件夹?): {name}') 255 | else: 256 | error(f'文件(夹)不存在: {name}') 257 | 258 | @require_login 259 | def rename(self, name): 260 | """重命名文件或文件夹(需要会员)""" 261 | if folder := self._dir_list.find_by_name(name): 262 | fid, is_file = folder.id, False 263 | elif file := self._file_list.find_by_name(name): 264 | fid, is_file = file.id, True 265 | else: 266 | error(f'没有这个文件(夹)的啦: {name}') 267 | return None 268 | 269 | new_name = input(f'重命名 "{name}" 为 ') or '' 270 | if not new_name: 271 | info(f'重命名操作取消') 272 | return None 273 | 274 | if is_file: 275 | if self._disk.rename_file(fid, new_name) != LanZouCloud.SUCCESS: 276 | error('(#°Д°) 文件重命名失败, 请开通会员,文件名不要带后缀') 277 | return None 278 | # 只更新本地索引的文件夹名(调用refresh()要等 1.5s 才能刷新信息) 279 | self._file_list.update_by_id(fid, name=name) 280 | else: 281 | if self._disk.rename_dir(fid, new_name) != LanZouCloud.SUCCESS: 282 | error('文件夹重命名失败') 283 | return None 284 | self._dir_list.update_by_id(fid, name=new_name) 285 | 286 | @require_login 287 | def mv(self, name): 288 | """移动文件或文件夹""" 289 | if file := self._file_list.find_by_name(name): 290 | fid, is_file = file.id, True 291 | elif folder := self._dir_list.find_by_name(name): 292 | fid, is_file = folder.id, False 293 | else: 294 | error(f"文件(夹)不存在: {name}") 295 | return None 296 | 297 | path_list = self._disk.get_move_paths() 298 | path_list = {'/'.join(path.all_name): path[-1].id for path in path_list} 299 | choice_list = list(path_list.keys()) 300 | 301 | def _condition(typed_str, choice_str): 302 | path_depth = len(choice_str.split('/')) 303 | # 没有输入时, 补全 LanZouCloud,深度 1 304 | if not typed_str and path_depth == 1: 305 | return True 306 | # LanZouCloud/ 深度为 2,补全同深度的文件夹 LanZouCloud/test 、LanZouCloud/txt 307 | # LanZouCloud/tx 应该补全 LanZouCloud/txt 308 | if path_depth == len(typed_str.split('/')) and choice_str.startswith(typed_str): 309 | return True 310 | 311 | set_completer(choice_list, condition=_condition) 312 | choice = input('请输入路径(TAB键补全) : ') 313 | if not choice or choice not in choice_list: 314 | error(f"目标路径不存在: {choice}") 315 | return None 316 | folder_id = path_list.get(choice) 317 | if is_file: 318 | if self._disk.move_file(fid, folder_id) == LanZouCloud.SUCCESS: 319 | self._file_list.pop_by_id(fid) 320 | else: 321 | error(f"移动文件到 {choice} 失败") 322 | else: 323 | if self._disk.move_folder(fid, folder_id) == LanZouCloud.SUCCESS: 324 | self._dir_list.pop_by_id(fid) 325 | else: 326 | error(f"移动文件夹到 {choice} 失败") 327 | 328 | def down(self, arg): 329 | """自动选择下载方式""" 330 | downloader = Downloader(self._disk) 331 | if arg.startswith('http'): 332 | downloader.set_url(arg) 333 | elif file := self._file_list.find_by_name(arg): # 如果是文件 334 | path = '/'.join(self._path_list.all_name) + '/' + arg # 文件在网盘的绝对路径 335 | downloader.set_fid(file.id, is_file=True, f_path=path) 336 | elif folder := self._dir_list.find_by_name(arg): # 如果是文件夹 337 | path = '/'.join(self._path_list.all_name) + '/' + arg + '/' # 文件夹绝对路径, 加 '/' 以便区分 338 | downloader.set_fid(folder.id, is_file=False, f_path=path) 339 | else: 340 | error(f'文件(夹)不存在: {arg}') 341 | return None 342 | # 提交下载任务 343 | info("下载任务已提交, 使用 jobs 命令查看进度, jobs ID 查看详情") 344 | self._task_mgr.add_task(downloader) 345 | 346 | def jobs(self, arg): 347 | """显示后台任务列表""" 348 | if arg.isnumeric(): 349 | self._task_mgr.show_detail(int(arg)) 350 | else: 351 | self._task_mgr.show_tasks() 352 | 353 | @require_login 354 | def upload(self, path): 355 | """上传文件(夹)""" 356 | path = path.strip('\"\' ') # 去除直接拖文件到窗口产生的引号 357 | if not os.path.exists(path): 358 | error(f'该路径不存在哦: {path}') 359 | return None 360 | uploader = Uploader(self._disk) 361 | uploader.set_uploaded_handler(lambda fid, isfile: self.refresh()) # 上传成功自动刷新 362 | if os.path.isfile(path): 363 | uploader.set_upload_path(path, is_file=True) 364 | else: 365 | uploader.set_upload_path(path, is_file=False) 366 | uploader.set_target(self._work_id, self._work_name) 367 | info("上传任务已提交, 使用 jobs 命令查看进度, jobs ID 查看详情") 368 | self._task_mgr.add_task(uploader) 369 | 370 | @require_login 371 | def share(self, name): 372 | """显示分享信息""" 373 | if file := self._file_list.find_by_name(name): # 文件 374 | inf = self._disk.get_file_info_by_id(file.id) 375 | if inf.code != LanZouCloud.SUCCESS: 376 | error('获取文件信息出错') 377 | return None 378 | 379 | print("-" * 50) 380 | print(f"文件名 : {name}") 381 | print(f"提取码 : {inf.pwd or '无'}") 382 | print(f"文件大小 : {inf.size}") 383 | print(f"上传时间 : {inf.time}") 384 | print(f"分享链接 : {inf.url}") 385 | print(f"描述信息 : {inf.desc or '无'}") 386 | print(f"下载直链 : {inf.durl or '无'}") 387 | print("-" * 50) 388 | 389 | elif folder := self._dir_list.find_by_name(name): # 文件夹 390 | inf = self._disk.get_share_info(folder.id, is_file=False) 391 | if inf.code != LanZouCloud.SUCCESS: 392 | print('ERROR : 获取文件夹信息出错') 393 | return None 394 | 395 | print("-" * 50) 396 | print(f"文件夹名 : {name}") 397 | print(f"提取码 : {inf.pwd or '无'}") 398 | print(f"分享链接 : {inf.url}") 399 | print(f"描述信息 : {inf.desc or '无'}") 400 | print("-" * 50) 401 | else: 402 | error(f"文件(夹)不存在: {name}") 403 | 404 | @require_login 405 | def passwd(self, name): 406 | """设置文件(夹)提取码""" 407 | if file := self._file_list.find_by_name(name): # 文件 408 | inf = self._disk.get_share_info(file.id, True) 409 | new_pass = input(f'修改提取码 "{inf.pwd or "无"}" -> ') 410 | if 2 <= len(new_pass) <= 6: 411 | if new_pass == 'off': new_pass = '' 412 | if self._disk.set_passwd(file.id, str(new_pass), True) != LanZouCloud.SUCCESS: 413 | error('设置文件提取码失败') 414 | self.refresh() 415 | else: 416 | error('提取码为2-6位字符,关闭请输入off') 417 | elif folder := self._dir_list.find_by_name(name): # 文件夹 418 | inf = self._disk.get_share_info(folder.id, False) 419 | new_pass = input(f'修改提取码 "{inf.pwd or "无"}" -> ') 420 | if 2 <= len(new_pass) <= 12: 421 | if new_pass == 'off': new_pass = '' 422 | if self._disk.set_passwd(folder.id, str(new_pass), False) != LanZouCloud.SUCCESS: 423 | error('设置文件夹提取码失败') 424 | self.refresh() 425 | else: 426 | error('提取码为2-12位字符,关闭请输入off') 427 | else: 428 | error(f'文件(夹)不存在: {name}') 429 | 430 | @require_login 431 | def desc(self, name): 432 | """设置文件描述""" 433 | if file := self._file_list.find_by_name(name): # 文件 434 | inf = self._disk.get_share_info(file.id, True) 435 | print(f"当前描述: {inf.desc or '无'}") 436 | desc = input(f'修改为 -> ') 437 | if not desc: 438 | error(f'文件描述不允许为空') 439 | return None 440 | if self._disk.set_desc(file.id, str(desc), True) != LanZouCloud.SUCCESS: 441 | error(f'文件描述修改失败') 442 | self.refresh() 443 | elif folder := self._dir_list.find_by_name(name): # 文件夹 444 | inf = self._disk.get_share_info(folder.id, False) 445 | print(f"当前描述: {inf.desc}") 446 | desc = input(f'修改为 -> ') or '' 447 | if self._disk.set_desc(folder.id, str(desc), False) == LanZouCloud.SUCCESS: 448 | if len(desc) == 0: 449 | info('文件夹描述已关闭') 450 | else: 451 | error(f'文件夹描述修改失败') 452 | self.refresh() 453 | else: 454 | error(f'文件(夹)不存在: {name}') 455 | 456 | @require_login 457 | def export(self, name): 458 | """文件链接信息导出到文件""" 459 | if folder := self._dir_list.find_by_name(name): 460 | if not os.path.exists(config.save_path): 461 | os.makedirs(config.save_path) 462 | csv_path = config.save_path + os.sep + strftime("%Y-%m-%d_%H-%M-%S", localtime()) + '.csv' 463 | print(f"正在导出 {name} 下的文件信息, 请稍等...") 464 | folder_info = self._disk.get_folder_info_by_id(folder.id) 465 | with open(csv_path, 'w', newline='', encoding='utf-8') as csv_file: 466 | writer = csv.writer(csv_file) 467 | for file in folder_info.files: 468 | writer.writerow([file.name, file.time, file.size, file.url]) 469 | 470 | info(f"信息已导出至文件: {csv_path}") 471 | else: 472 | error(f'文件夹不存在: {name}') 473 | 474 | def setpath(self): 475 | """设置下载路径""" 476 | print(f"当前下载路径 : {config.save_path}") 477 | path = input('修改为 -> ').strip("\"\' ") 478 | if os.path.isdir(path): 479 | config.save_path = path 480 | else: 481 | error('路径非法,取消修改') 482 | 483 | def setsize(self): 484 | """设置上传限制""" 485 | print(f"当前限制(MB): {config.max_size}") 486 | max_size = input('修改为 -> ') 487 | if not max_size.isnumeric(): 488 | error("请输入大于 100 的数字") 489 | return None 490 | if self._disk.set_max_size(int(max_size)) != LanZouCloud.SUCCESS: 491 | error("设置失败,限制值必需大于 100") 492 | return None 493 | config.max_size = int(max_size) 494 | 495 | def setdelay(self): 496 | """设置大文件上传延时""" 497 | print("大文件数据块上传延时范围(秒), 如: 0 60") 498 | print(f"当前配置: {config.upload_delay}") 499 | tr = input("请输入延时范围: ").split() 500 | if len(tr) != 2: 501 | error("格式有误!") 502 | return None 503 | tr = (int(tr[0]), int(tr[1])) 504 | self._disk.set_upload_delay(tr) 505 | config.upload_delay = tr 506 | 507 | def setpasswd(self): 508 | """设置文件(夹)默认上传密码""" 509 | print("关闭提取码请输入 off") 510 | print(f"当前配置: 文件: {config.default_file_pwd or '无'}, 文件夹: {config.default_dir_pwd or '无'}") 511 | file_pwd = input("设置文件默认提取码(2-6位): ") 512 | if 2 <= len(file_pwd) <= 6: 513 | config.default_file_pwd = '' if file_pwd == 'off' else file_pwd 514 | dir_pwd = input("设置文件夹默认提取码(2-12位): ") 515 | if 2 <= len(dir_pwd) <= 12: 516 | config.default_dir_pwd = '' if dir_pwd == 'off' else dir_pwd 517 | info(f"修改成功: 文件: {config.default_file_pwd or '无'}, 文件夹: {config.default_dir_pwd or '无'}, 配置将在下次启动时生效") 518 | 519 | def run(self): 520 | """处理一条用户命令""" 521 | no_arg_cmd = ['bye', 'cdrec', 'clear', 'help', 'login', 'logout', 'ls', 'refresh', 'rmode', 'setpath', 522 | 'setsize', 'update', 'xghost', 'setdelay', 'setpasswd'] 523 | cmd_with_arg = ['cd', 'desc', 'down', 'jobs', 'mkdir', 'mv', 'passwd', 'rename', 'rm', 'share', 'upload', 524 | 'export'] 525 | 526 | choice_list = self._file_list.all_name + self._dir_list.all_name 527 | cmd_list = no_arg_cmd + cmd_with_arg 528 | set_completer(choice_list, cmd_list=cmd_list) 529 | 530 | try: 531 | args = input(self._prompt).split(' ', 1) 532 | if len(args) == 0: 533 | return None 534 | except KeyboardInterrupt: 535 | print('') 536 | info('退出本程序请输入 bye') 537 | return None 538 | 539 | cmd, arg = (args[0], '') if len(args) == 1 else (args[0], args[1]) # 命令, 参数(可带有空格, 没有参数就设为空) 540 | 541 | if cmd in no_arg_cmd: 542 | getattr(self, cmd)() 543 | elif cmd in cmd_with_arg: 544 | getattr(self, cmd)(arg) 545 | -------------------------------------------------------------------------------- /lanzou/cmder/browser_cookie.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Source: https://github.com/borisbabic/browser_cookie3 4 | # Modified by @rachpt and @zaxtyson, see: 5 | # https://github.com/zaxtyson/LanZouCloud-CMD/issues/59 6 | 7 | import base64 8 | import configparser 9 | import datetime 10 | import glob 11 | import http.cookiejar 12 | import os 13 | import os.path 14 | import sys 15 | import tempfile 16 | import time 17 | from typing import Union, Iterable 18 | 19 | import lz4.block 20 | from Crypto.Cipher import AES 21 | 22 | try: 23 | import json 24 | except ImportError: 25 | import simplejson as json 26 | try: 27 | # should use pysqlite2 to read the cookies.sqlite on Windows 28 | # otherwise will raise the "sqlite3.DatabaseError: file is encrypted or is not a database" exception 29 | from pysqlite2 import dbapi2 as sqlite3 30 | except ImportError: 31 | import sqlite3 32 | 33 | # external dependencies 34 | import pyaes 35 | from pbkdf2 import PBKDF2 36 | 37 | __doc__ = 'Load browser cookies into a cookiejar' 38 | 39 | 40 | class BrowserCookieError(Exception): 41 | pass 42 | 43 | 44 | def create_local_copy(cookie_file): 45 | """Make a local copy of the sqlite cookie database and return the new filename. 46 | This is necessary in case this database is still being written to while the user browses 47 | to avoid sqlite locking errors. 48 | """ 49 | # check if cookie file exists 50 | if os.path.exists(cookie_file): 51 | # copy to random name in tmp folder 52 | tmp_cookie_file = tempfile.NamedTemporaryFile(suffix='.sqlite').name 53 | open(tmp_cookie_file, 'wb').write(open(cookie_file, 'rb').read()) 54 | return tmp_cookie_file 55 | else: 56 | raise BrowserCookieError('Can not find cookie file at: ' + cookie_file) 57 | 58 | 59 | def windows_group_policy_path(): 60 | # we know that we're running under windows at this point so it's safe to do these imports 61 | from winreg import ConnectRegistry, HKEY_LOCAL_MACHINE, OpenKeyEx, QueryValueEx, REG_EXPAND_SZ, REG_SZ 62 | try: 63 | root = ConnectRegistry(None, HKEY_LOCAL_MACHINE) 64 | policy_key = OpenKeyEx(root, r"SOFTWARE\Policies\Google\Chrome") 65 | user_data_dir, type_ = QueryValueEx(policy_key, "UserDataDir") 66 | if type_ == REG_EXPAND_SZ: 67 | user_data_dir = os.path.expandvars(user_data_dir) 68 | elif type_ != REG_SZ: 69 | return None 70 | except OSError: 71 | return None 72 | return os.path.join(user_data_dir, "Default", "Cookies") 73 | 74 | 75 | # Code adapted slightly from https://github.com/Arnie97/chrome-cookies 76 | def crypt_unprotect_data( 77 | cipher_text=b'', entropy=b'', reserved=None, prompt_struct=None, is_key=False 78 | ): 79 | # we know that we're running under windows at this point so it's safe to try these imports 80 | import ctypes 81 | import ctypes.wintypes 82 | 83 | class DataBlob(ctypes.Structure): 84 | _fields_ = [ 85 | ('cbData', ctypes.wintypes.DWORD), 86 | ('pbData', ctypes.POINTER(ctypes.c_char)) 87 | ] 88 | 89 | blob_in, blob_entropy, blob_out = map( 90 | lambda x: DataBlob(len(x), ctypes.create_string_buffer(x)), 91 | [cipher_text, entropy, b''] 92 | ) 93 | desc = ctypes.c_wchar_p() 94 | 95 | CRYPTPROTECT_UI_FORBIDDEN = 0x01 96 | 97 | if not ctypes.windll.crypt32.CryptUnprotectData( 98 | ctypes.byref(blob_in), ctypes.byref( 99 | desc), ctypes.byref(blob_entropy), 100 | reserved, prompt_struct, CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref( 101 | blob_out) 102 | ): 103 | raise RuntimeError('Failed to decrypt the cipher text with DPAPI') 104 | 105 | description = desc.value 106 | buffer_out = ctypes.create_string_buffer(int(blob_out.cbData)) 107 | ctypes.memmove(buffer_out, blob_out.pbData, blob_out.cbData) 108 | map(ctypes.windll.kernel32.LocalFree, [desc, blob_out.pbData]) 109 | if is_key: 110 | return description, buffer_out.raw 111 | else: 112 | return description, buffer_out.value 113 | 114 | 115 | def get_linux_pass(os_crypt_name): 116 | """Retrive password used to encrypt cookies from libsecret. 117 | """ 118 | # https://github.com/n8henrie/pycookiecheat/issues/12 119 | my_pass = None 120 | 121 | import secretstorage 122 | connection = secretstorage.dbus_init() 123 | collection = secretstorage.get_default_collection(connection) 124 | secret = None 125 | 126 | # we should not look for secret with label. Sometimes label can be different. For example, 127 | # if Steam is installed before Chromium, Opera or Edge, it will show Steam Secret Storage as label. 128 | # insted we should look with schema and application 129 | secret = next(collection.search_items( 130 | {'xdg:schema': 'chrome_libsecret_os_crypt_password_v2', 131 | 'application': os_crypt_name}), None) 132 | 133 | if not secret: 134 | # trying os_crypt_v1 135 | secret = next(collection.search_items( 136 | {'xdg:schema': 'chrome_libsecret_os_crypt_password_v1', 137 | 'application': os_crypt_name}), None) 138 | 139 | if secret: 140 | my_pass = secret.get_secret() 141 | 142 | connection.close() 143 | 144 | # Try to get pass from keyring, which should support KDE / KWallet 145 | if not my_pass: 146 | try: 147 | import keyring.backends.kwallet 148 | keyring.set_keyring(keyring.backends.kwallet.DBusKeyring()) 149 | my_pass = keyring.get_password( 150 | "{} Keys".format(os_crypt_name.capitalize()), 151 | "{} Safe Storage".format(os_crypt_name.capitalize()) 152 | ).encode('utf-8') 153 | # it may raise `dbus.exceptions.DBusException` `eyring.errors.InitError` 154 | # on Gnome environment, all of them extended from `Exception` 155 | except Exception: 156 | pass 157 | 158 | # try default peanuts password, probably won't work 159 | if not my_pass: 160 | my_pass = 'peanuts'.encode('utf-8') 161 | 162 | return my_pass 163 | 164 | 165 | def __expand_win_path(path: Union[dict, str]): 166 | if not isinstance(path, dict): 167 | path = {'path': path} 168 | return os.path.join(os.getenv(path['env'], ''), path['path']) 169 | 170 | 171 | def __add_more_possible_paths(paths: Iterable[str]): 172 | # User data may saved in ../User Data/[Default|Profile N|Guest Profile]/... 173 | possible_path_list = [] 174 | subs = ['Profile ' + str(n) for n in range(1, 10)] + ['Guest Profile'] 175 | 176 | for path in paths: 177 | possible_path_list.append(path) 178 | if 'Default' not in path: 179 | continue 180 | for sub in subs: 181 | possible_path_list.append(path.replace('Default', sub)) 182 | return possible_path_list 183 | 184 | 185 | def expand_paths(paths: list, os_name: str): 186 | """Expands user paths on Linux, OSX, and windows 187 | """ 188 | 189 | os_name = os_name.lower() 190 | assert os_name in ['windows', 'osx', 'linux'] 191 | 192 | if not isinstance(paths, list): 193 | paths = [paths] 194 | 195 | if os_name == 'windows': 196 | paths = map(__expand_win_path, paths) 197 | else: 198 | paths = map(os.path.expanduser, paths) 199 | 200 | paths = __add_more_possible_paths(paths) 201 | paths = next(filter(os.path.exists, paths), None) 202 | return paths 203 | 204 | 205 | class ChromiumBased: 206 | """Super class for all Chromium based browser. 207 | """ 208 | 209 | def __init__(self, browser: str, cookie_file=None, domain_name="", key_file=None, **kwargs): 210 | self.salt = b'saltysalt' 211 | self.iv = b' ' * 16 212 | self.length = 16 213 | self.browser = browser 214 | self.cookie_file = cookie_file 215 | self.domain_name = domain_name 216 | self.key_file = key_file 217 | self.__add_key_and_cookie_file(**kwargs) 218 | 219 | def __add_key_and_cookie_file(self, 220 | linux_cookies=None, windows_cookies=None, osx_cookies=None, 221 | windows_keys=None, os_crypt_name=None, osx_key_service=None, osx_key_user=None): 222 | 223 | if sys.platform == 'darwin': 224 | # running Chromium or it's derivatives on OSX 225 | import keyring.backends.OS_X 226 | keyring.set_keyring(keyring.backends.OS_X.Keyring()) 227 | my_pass = keyring.get_password(osx_key_service, osx_key_user) 228 | 229 | # try default peanuts password, probably won't work 230 | if not my_pass: 231 | my_pass = 'peanuts' 232 | my_pass = my_pass.encode('utf-8') 233 | 234 | iterations = 1003 # number of pbkdf2 iterations on mac 235 | self.key = PBKDF2(my_pass, self.salt, 236 | iterations=iterations).read(self.length) 237 | 238 | cookie_file = self.cookie_file or expand_paths(osx_cookies, 'osx') 239 | 240 | elif sys.platform.startswith('linux'): 241 | my_pass = get_linux_pass(os_crypt_name) 242 | 243 | iterations = 1 244 | self.key = PBKDF2(my_pass, self.salt, 245 | iterations=iterations).read(self.length) 246 | 247 | cookie_file = self.cookie_file or expand_paths(linux_cookies, 'linux') 248 | 249 | elif sys.platform == "win32": 250 | key_file = self.key_file or expand_paths(windows_keys, 'windows') 251 | 252 | if key_file: 253 | with open(key_file, 'rb') as f: 254 | key_file_json = json.load(f) 255 | key64 = key_file_json['os_crypt']['encrypted_key'].encode('utf-8') 256 | 257 | # Decode Key, get rid of DPAPI prefix, unprotect data 258 | keydpapi = base64.standard_b64decode(key64)[5:] 259 | _, self.key = crypt_unprotect_data(keydpapi, is_key=True) 260 | 261 | # get cookie file from APPDATA 262 | 263 | cookie_file = self.cookie_file 264 | 265 | if not cookie_file: 266 | if self.browser.lower() == 'chrome' and windows_group_policy_path(): 267 | cookie_file = windows_group_policy_path() 268 | else: 269 | cookie_file = expand_paths(windows_cookies, 'windows') 270 | 271 | else: 272 | raise BrowserCookieError( 273 | "OS not recognized. Works on OSX, Windows, and Linux.") 274 | 275 | if not cookie_file: 276 | raise BrowserCookieError('Failed to find {} cookie'.format(self.browser)) 277 | 278 | self.tmp_cookie_file = create_local_copy(cookie_file) 279 | 280 | def __del__(self): 281 | # remove temporary backup of sqlite cookie database 282 | if hasattr(self, 'tmp_cookie_file'): # if there was an error till here 283 | os.remove(self.tmp_cookie_file) 284 | 285 | def __str__(self): 286 | return self.browser 287 | 288 | def load(self): 289 | """Load sqlite cookies into a cookiejar 290 | """ 291 | con = sqlite3.connect(self.tmp_cookie_file) 292 | cur = con.cursor() 293 | try: 294 | # chrome <=55 295 | cur.execute('SELECT host_key, path, secure, expires_utc, name, value, encrypted_value ' 296 | 'FROM cookies WHERE host_key like "%{}%";'.format(self.domain_name)) 297 | except sqlite3.OperationalError: 298 | # chrome >=56 299 | cur.execute('SELECT host_key, path, is_secure, expires_utc, name, value, encrypted_value ' 300 | 'FROM cookies WHERE host_key like "%{}%";'.format(self.domain_name)) 301 | 302 | cj = http.cookiejar.CookieJar() 303 | epoch_start = datetime.datetime(1601, 1, 1) 304 | for item in cur.fetchall(): 305 | host, path, secure, expires, name = item[:5] 306 | if item[3] != 0: 307 | # ensure dates don't exceed the datetime limit of year 10000 308 | try: 309 | offset = min(int(item[3]), 265000000000000000) 310 | delta = datetime.timedelta(microseconds=offset) 311 | expires = epoch_start + delta 312 | expires = expires.timestamp() 313 | # Windows 7 has a further constraint 314 | except OSError: 315 | offset = min(int(item[3]), 32536799999000000) 316 | delta = datetime.timedelta(microseconds=offset) 317 | expires = epoch_start + delta 318 | expires = expires.timestamp() 319 | 320 | value = self._decrypt(item[5], item[6]) 321 | c = create_cookie(host, path, secure, expires, name, value) 322 | cj.set_cookie(c) 323 | con.close() 324 | return cj 325 | 326 | @staticmethod 327 | def _decrypt_windows_chromium(value, encrypted_value): 328 | 329 | if len(value) != 0: 330 | return value 331 | 332 | if encrypted_value == "": 333 | return "" 334 | 335 | _, data = crypt_unprotect_data(encrypted_value) 336 | assert isinstance(data, bytes) 337 | return data.decode() 338 | 339 | def _decrypt(self, value, encrypted_value): 340 | """Decrypt encoded cookies 341 | """ 342 | 343 | if sys.platform == 'win32': 344 | try: 345 | return self._decrypt_windows_chromium(value, encrypted_value) 346 | 347 | # Fix for change in Chrome 80 348 | except RuntimeError: # Failed to decrypt the cipher text with DPAPI 349 | if not self.key: 350 | raise RuntimeError( 351 | 'Failed to decrypt the cipher text with DPAPI and no AES key.') 352 | # Encrypted cookies should be prefixed with 'v10' according to the 353 | # Chromium code. Strip it off. 354 | encrypted_value = encrypted_value[3:] 355 | nonce, tag = encrypted_value[:12], encrypted_value[-16:] 356 | aes = AES.new(self.key, AES.MODE_GCM, nonce=nonce) 357 | 358 | # will rise Value Error: MAC check failed byte if the key is wrong, 359 | # probably we did not got the key and used peanuts 360 | try: 361 | data = aes.decrypt_and_verify(encrypted_value[12:-16], tag) 362 | except ValueError: 363 | raise BrowserCookieError('Unable to get key for cookie decryption') 364 | return data.decode() 365 | 366 | if value or (encrypted_value[:3] not in [b'v11', b'v10']): 367 | return value 368 | 369 | # Encrypted cookies should be prefixed with 'v10' according to the 370 | # Chromium code. Strip it off. 371 | encrypted_value = encrypted_value[3:] 372 | encrypted_value_half_len = int(len(encrypted_value) / 2) 373 | 374 | cipher = pyaes.Decrypter( 375 | pyaes.AESModeOfOperationCBC(self.key, self.iv)) 376 | 377 | # will rise Value Error: invalid padding byte if the key is wrong, 378 | # probably we did not got the key and used peanuts 379 | try: 380 | decrypted = cipher.feed(encrypted_value[:encrypted_value_half_len]) 381 | decrypted += cipher.feed(encrypted_value[encrypted_value_half_len:]) 382 | decrypted += cipher.feed() 383 | except ValueError: 384 | raise BrowserCookieError('Unable to get key for cookie decryption') 385 | return decrypted.decode("utf-8") 386 | 387 | 388 | class Chrome(ChromiumBased): 389 | def __init__(self, cookie_file=None, domain_name="", key_file=None): 390 | args = { 391 | 'linux_cookies': [ 392 | '~/.config/google-chrome/Default/Cookies', 393 | '~/.config/google-chrome-beta/Default/Cookies' 394 | ], 395 | 'windows_cookies': [ 396 | {'env': 'APPDATA', 'path': '..\\Local\\Google\\Chrome\\User Data\\Default\\Cookies'}, 397 | {'env': 'LOCALAPPDATA', 'path': 'Google\\Chrome\\User Data\\Default\\Cookies'}, 398 | {'env': 'APPDATA', 'path': 'Google\\Chrome\\User Data\\Default\\Cookies'} 399 | ], 400 | 'osx_cookies': ['~/Library/Application Support/Google/Chrome/Default/Cookies'], 401 | 'windows_keys': [ 402 | {'env': 'APPDATA', 'path': '..\\Local\\Google\\Chrome\\User Data\\Local State'}, 403 | {'env': 'LOCALAPPDATA', 'path': 'Google\\Chrome\\User Data\\Local State'}, 404 | {'env': 'APPDATA', 'path': 'Google\\Chrome\\User Data\\Local State'} 405 | ], 406 | 'os_crypt_name': 'chrome', 407 | 'osx_key_service': 'Chrome Safe Storage', 408 | 'osx_key_user': 'Chrome' 409 | } 410 | 411 | super().__init__(browser='Chrome', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, **args) 412 | 413 | 414 | class Chromium(ChromiumBased): 415 | def __init__(self, cookie_file=None, domain_name="", key_file=None): 416 | args = { 417 | 'linux_cookies': ['~/.config/chromium/Default/Cookies'], 418 | 'windows_cookies': [ 419 | {'env': 'APPDATA', 'path': '..\\Local\\Chromium\\User Data\\Default\\Cookies'}, 420 | {'env': 'LOCALAPPDATA', 'path': 'Chromium\\User Data\\Default\\Cookies'}, 421 | {'env': 'APPDATA', 'path': 'Chromium\\User Data\\Default\\Cookies'} 422 | ], 423 | 'osx_cookies': ['~/Library/Application Support/Chromium/Default/Cookies'], 424 | 'windows_keys': [ 425 | {'env': 'APPDATA', 'path': '..\\Local\\Chromium\\User Data\\Local State'}, 426 | {'env': 'LOCALAPPDATA', 'path': 'Chromium\\User Data\\Local State'}, 427 | {'env': 'APPDATA', 'path': 'Chromium\\User Data\\Local State'} 428 | ], 429 | 'os_crypt_name': 'chromium', 430 | 'osx_key_service': 'Chromium Safe Storage', 431 | 'osx_key_user': 'Chromium' 432 | } 433 | super().__init__(browser='Chromium', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, 434 | **args) 435 | 436 | 437 | class Opera(ChromiumBased): 438 | def __init__(self, cookie_file=None, domain_name="", key_file=None): 439 | args = { 440 | 'linux_cookies': ['~/.config/opera/Cookies'], 441 | 'windows_cookies': [ 442 | {'env': 'APPDATA', 'path': '..\\Local\\Opera Software\\Opera Stable\\Cookies'}, 443 | {'env': 'LOCALAPPDATA', 'path': 'Opera Software\\Opera Stable\\Cookies'}, 444 | {'env': 'APPDATA', 'path': 'Opera Software\\Opera Stable\\Cookies'} 445 | ], 446 | 'osx_cookies': ['~/Library/Application Support/com.operasoftware.Opera/Cookies'], 447 | 'windows_keys': [ 448 | {'env': 'APPDATA', 'path': '..\\Local\\Opera Software\\Opera Stable\\Local State'}, 449 | {'env': 'LOCALAPPDATA', 'path': 'Opera Software\\Opera Stable\\Local State'}, 450 | {'env': 'APPDATA', 'path': 'Opera Software\\Opera Stable\\Local State'} 451 | ], 452 | 'os_crypt_name': 'chromium', 453 | 'osx_key_service': 'Opera Safe Storage', 454 | 'osx_key_user': 'Opera' 455 | } 456 | 457 | super().__init__(browser='Opera', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, **args) 458 | 459 | 460 | class Edge(ChromiumBased): 461 | def __init__(self, cookie_file=None, domain_name="", key_file=None): 462 | args = { 463 | 'linux_cookies': [ 464 | '~/.config/microsoft-edge/Default/Cookies', 465 | '~/.config/microsoft-edge-dev/Default/Cookies' 466 | ], 467 | 'windows_cookies': [ 468 | # Chromium Edge on Windows11 469 | {'env': 'APPDATA', 'path': '..\\Local\\Microsoft\\Edge\\User Data\\Default\\Network\\Cookies'}, 470 | {'env': 'APPDATA', 'path': '..\\Local\\Microsoft\\Edge\\User Data\\Default\\Cookies'}, 471 | {'env': 'LOCALAPPDATA', 'path': 'Microsoft\\Edge\\User Data\\Default\\Cookies'}, 472 | {'env': 'APPDATA', 'path': 'Microsoft\\Edge\\User Data\\Default\\Cookies'} 473 | ], 474 | 'osx_cookies': ['~/Library/Application Support/Microsoft Edge/Default/Cookies'], 475 | 'windows_keys': [ 476 | {'env': 'APPDATA', 'path': '..\\Local\\Microsoft\\Edge\\User Data\\Local State'}, 477 | {'env': 'LOCALAPPDATA', 'path': 'Microsoft\\Edge\\User Data\\Local State'}, 478 | {'env': 'APPDATA', 'path': 'Microsoft\\Edge\\User Data\\Local State'} 479 | ], 480 | 'os_crypt_name': 'chromium', 481 | 'osx_key_service': 'Microsoft Edge Safe Storage', 482 | 'osx_key_user': 'Microsoft Edge' 483 | } 484 | 485 | super().__init__(browser='Edge', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, **args) 486 | 487 | 488 | class Firefox: 489 | def __init__(self, cookie_file=None, domain_name=""): 490 | self.tmp_cookie_file = None 491 | cookie_file = cookie_file or self.find_cookie_file() 492 | self.tmp_cookie_file = create_local_copy(cookie_file) 493 | # current sessions are saved in sessionstore.js 494 | self.session_file = os.path.join( 495 | os.path.dirname(cookie_file), 'sessionstore.js') 496 | self.session_file_lz4 = os.path.join(os.path.dirname( 497 | cookie_file), 'sessionstore-backups', 'recovery.jsonlz4') 498 | # domain name to filter cookies by 499 | self.domain_name = domain_name 500 | 501 | def __del__(self): 502 | # remove temporary backup of sqlite cookie database 503 | if self.tmp_cookie_file: 504 | os.remove(self.tmp_cookie_file) 505 | 506 | def __str__(self): 507 | return 'firefox' 508 | 509 | @staticmethod 510 | def get_default_profile(user_data_path): 511 | config = configparser.ConfigParser() 512 | profiles_ini_path = glob.glob(os.path.join( 513 | user_data_path + '**', 'profiles.ini')) 514 | fallback_path = user_data_path + '**' 515 | 516 | if not profiles_ini_path: 517 | return fallback_path 518 | 519 | profiles_ini_path = profiles_ini_path[0] 520 | config.read(profiles_ini_path) 521 | 522 | profile_path = None 523 | for section in config.sections(): 524 | if section.startswith('Install'): 525 | profile_path = config[section].get('Default') 526 | break 527 | # in ff 72.0.1, if both an Install section and one with Default=1 are present, the former takes precedence 528 | elif config[section].get('Default') == '1' and not profile_path: 529 | profile_path = config[section].get('Path') 530 | 531 | for section in config.sections(): 532 | # the Install section has no relative/absolute info, so check the profiles 533 | if config[section].get('Path') == profile_path: 534 | absolute = config[section].get('IsRelative') == '0' 535 | return profile_path if absolute else os.path.join(os.path.dirname(profiles_ini_path), profile_path) 536 | 537 | return fallback_path 538 | 539 | @staticmethod 540 | def find_cookie_file(): 541 | cookie_files = [] 542 | 543 | if sys.platform == 'darwin': 544 | user_data_path = os.path.expanduser( 545 | '~/Library/Application Support/Firefox') 546 | elif sys.platform.startswith('linux'): 547 | user_data_path = os.path.expanduser('~/.mozilla/firefox') 548 | elif sys.platform == 'win32': 549 | user_data_path = os.path.join( 550 | os.environ.get('APPDATA'), 'Mozilla', 'Firefox') 551 | # legacy firefox <68 fallback 552 | cookie_files = glob.glob( 553 | os.path.join(os.environ.get('PROGRAMFILES'), 'Mozilla Firefox', 'profile', 'cookies.sqlite')) \ 554 | or glob.glob( 555 | os.path.join(os.environ.get('PROGRAMFILES(X86)'), 'Mozilla Firefox', 'profile', 'cookies.sqlite')) 556 | else: 557 | raise BrowserCookieError( 558 | 'Unsupported operating system: ' + sys.platform) 559 | cookie_files = glob.glob(os.path.join(Firefox.get_default_profile(user_data_path), 'cookies.sqlite')) \ 560 | or cookie_files 561 | 562 | if cookie_files: 563 | return cookie_files[0] 564 | else: 565 | raise BrowserCookieError('Failed to find Firefox cookie') 566 | 567 | @staticmethod 568 | def __create_session_cookie(cookie_json): 569 | expires = str(int(time.time()) + 3600 * 24 * 7) 570 | # return create_cookie(cookie_json.get('host', ''), cookie_json.get('path', ''), False, expires, 571 | # cookie_json.get('name', ''), cookie_json.get('value', '')) 572 | return create_cookie(cookie_json.get('host', ''), cookie_json.get('path', ''), 573 | cookie_json.get('secure', False), expires, 574 | cookie_json.get('name', ''), cookie_json.get('value', '')) 575 | 576 | def __add_session_cookies(self, cj): 577 | if not os.path.exists(self.session_file): 578 | return 579 | try: 580 | json_data = json.loads( 581 | open(self.session_file, 'rb').read().decode()) 582 | except ValueError as e: 583 | print('Error parsing firefox session JSON:', str(e)) 584 | else: 585 | for window in json_data.get('windows', []): 586 | for cookie in window.get('cookies', []): 587 | if self.domain_name == '' or self.domain_name in cookie.get('host', ''): 588 | cj.set_cookie(Firefox.__create_session_cookie(cookie)) 589 | 590 | def __add_session_cookies_lz4(self, cj): 591 | if not os.path.exists(self.session_file_lz4): 592 | return 593 | try: 594 | file_obj = open(self.session_file_lz4, 'rb') 595 | file_obj.read(8) 596 | json_data = json.loads(lz4.block.decompress(file_obj.read())) 597 | except ValueError as e: 598 | print('Error parsing firefox session JSON LZ4:', str(e)) 599 | else: 600 | for cookie in json_data.get('cookies', []): 601 | if self.domain_name == '' or self.domain_name in cookie.get('host', ''): 602 | cj.set_cookie(Firefox.__create_session_cookie(cookie)) 603 | 604 | def load(self): 605 | con = sqlite3.connect(self.tmp_cookie_file) 606 | cur = con.cursor() 607 | cur.execute('select host, path, isSecure, expiry, name, value from moz_cookies ' 608 | 'where host like "%{}%"'.format(self.domain_name)) 609 | 610 | cj = http.cookiejar.CookieJar() 611 | for item in cur.fetchall(): 612 | c = create_cookie(*item) 613 | cj.set_cookie(c) 614 | con.close() 615 | 616 | self.__add_session_cookies(cj) 617 | self.__add_session_cookies_lz4(cj) 618 | 619 | return cj 620 | 621 | 622 | def create_cookie(host, path, secure, expires, name, value): 623 | """Shortcut function to create a cookie 624 | """ 625 | return http.cookiejar.Cookie(0, name, value, None, False, host, host.startswith('.'), host.startswith('.'), path, 626 | True, secure, expires, False, None, None, {}) 627 | 628 | 629 | def chrome(cookie_file=None, domain_name="", key_file=None): 630 | """Returns a cookiejar of the cookies used by Chrome. Optionally pass in a 631 | domain name to only load cookies from the specified domain 632 | """ 633 | return Chrome(cookie_file, domain_name, key_file).load() 634 | 635 | 636 | def chromium(cookie_file=None, domain_name="", key_file=None): 637 | """Returns a cookiejar of the cookies used by Chromium. Optionally pass in a 638 | domain name to only load cookies from the specified domain 639 | """ 640 | return Chromium(cookie_file, domain_name, key_file).load() 641 | 642 | 643 | def opera(cookie_file=None, domain_name="", key_file=None): 644 | """Returns a cookiejar of the cookies used by Opera. Optionally pass in a 645 | domain name to only load cookies from the specified domain 646 | """ 647 | return Opera(cookie_file, domain_name, key_file).load() 648 | 649 | 650 | def edge(cookie_file=None, domain_name="", key_file=None): 651 | """Returns a cookiejar of the cookies used by Microsoft Egde. Optionally pass in a 652 | domain name to only load cookies from the specified domain 653 | """ 654 | return Edge(cookie_file, domain_name, key_file).load() 655 | 656 | 657 | def firefox(cookie_file=None, domain_name=""): 658 | """Returns a cookiejar of the cookies and sessions used by Firefox. Optionally 659 | pass in a domain name to only load cookies from the specified domain 660 | """ 661 | return Firefox(cookie_file, domain_name).load() 662 | 663 | 664 | def load(domain_name=""): 665 | """Try to load cookies from all supported browsers and return combined cookiejar 666 | Optionally pass in a domain name to only load cookies from the specified domain 667 | """ 668 | cj = http.cookiejar.CookieJar() 669 | for cookie_fn in [chrome, chromium, opera, edge, firefox]: 670 | try: 671 | for cookie in cookie_fn(domain_name=domain_name): 672 | cj.set_cookie(cookie) 673 | except BrowserCookieError: 674 | pass 675 | return cj 676 | 677 | 678 | def load_with_keys(domain_name="", keys: list = None) -> (dict, str): 679 | """Try to load cookies from `the first available browser`, return 680 | the required key-value pairs and browser name 681 | """ 682 | cookie = {} 683 | browser = "" 684 | for cookie_fn in [edge, firefox]: 685 | try: 686 | cookie.clear() 687 | for c in cookie_fn(domain_name=domain_name): 688 | if c.name in keys: 689 | cookie[c.name] = c.value 690 | if all(map(cookie.get, keys)): 691 | return cookie, cookie_fn.__name__ 692 | except BrowserCookieError: 693 | pass 694 | return cookie, browser 695 | 696 | 697 | if __name__ == '__main__': 698 | cookie, browser = load_with_keys('.woozooo.com', ['ylogin', 'phpdisk_info']) 699 | print("cookie from: " + browser) 700 | print(cookie) 701 | -------------------------------------------------------------------------------- /lanzou/api/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | 蓝奏网盘 API,封装了对蓝奏云的各种操作,解除了上传格式、大小限制 3 | """ 4 | 5 | import os 6 | import pickle 7 | import re 8 | import shutil 9 | from concurrent.futures import ThreadPoolExecutor, as_completed 10 | from datetime import datetime 11 | from random import shuffle, uniform 12 | from time import sleep 13 | from typing import List 14 | 15 | import requests 16 | from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor 17 | from urllib3 import disable_warnings 18 | from urllib3.exceptions import InsecureRequestWarning 19 | 20 | from lanzou.api.models import FileList, FolderList 21 | from lanzou.api.types import * 22 | from lanzou.api.utils import * 23 | 24 | __all__ = ['LanZouCloud'] 25 | 26 | 27 | class LanZouCloud(object): 28 | FAILED = -1 29 | SUCCESS = 0 30 | ID_ERROR = 1 31 | PASSWORD_ERROR = 2 32 | LACK_PASSWORD = 3 33 | ZIP_ERROR = 4 34 | MKDIR_ERROR = 5 35 | URL_INVALID = 6 36 | FILE_CANCELLED = 7 37 | PATH_ERROR = 8 38 | NETWORK_ERROR = 9 39 | CAPTCHA_ERROR = 10 40 | OFFICIAL_LIMITED = 11 41 | 42 | def __init__(self): 43 | self._session = requests.Session() 44 | self._limit_mode = True # 是否保持官方限制 45 | self._timeout = 15 # 每个请求的超时(不包含下载响应体的用时) 46 | self._max_size = 100 # 单个文件大小上限 MB 47 | self._upload_delay = (0, 0) # 文件上传延时 48 | self._host_url = 'https://pan.lanzouo.com' 49 | self._doupload_url = 'https://pc.woozooo.com/doupload.php' 50 | self._account_url = 'https://pc.woozooo.com/account.php' 51 | self._mydisk_url = 'https://pc.woozooo.com/mydisk.php' 52 | self._cookies = None 53 | self._headers = { 54 | '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', 55 | 'Referer': 'https://pc.woozooo.com/mydisk.php', 56 | 'Accept-Language': 'zh-CN,zh;q=0.9', # 提取直连必需设置这个,否则拿不到数据 57 | } 58 | disable_warnings(InsecureRequestWarning) # 全局禁用 SSL 警告 59 | 60 | def _get(self, url, **kwargs): 61 | for possible_url in self._all_possible_urls(url): 62 | try: 63 | kwargs.setdefault('timeout', self._timeout) 64 | kwargs.setdefault('headers', self._headers) 65 | return self._session.get(possible_url, verify=False, **kwargs) 66 | except (ConnectionError, requests.RequestException): 67 | logger.debug(f"Get {possible_url} failed, try another domain") 68 | 69 | return None 70 | 71 | def _post(self, url, data, **kwargs): 72 | for possible_url in self._all_possible_urls(url): 73 | try: 74 | kwargs.setdefault('timeout', self._timeout) 75 | kwargs.setdefault('headers', self._headers) 76 | return self._session.post(possible_url, data, verify=False, **kwargs) 77 | except (ConnectionError, requests.RequestException): 78 | logger.debug(f"Post to {possible_url} ({data}) failed, try another domain") 79 | 80 | return None 81 | 82 | @staticmethod 83 | def _all_possible_urls(url: str) -> List[str]: 84 | """蓝奏云的主域名有时会挂掉, 此时尝试切换到备用域名""" 85 | available_domains = [ 86 | 'lanzouw.com', # 鲁ICP备15001327号-7, 2021-09-02 87 | 'lanzoui.com', # 鲁ICP备15001327号-6, 2020-06-09 88 | 'lanzoux.com' # 鲁ICP备15001327号-5, 2020-06-09 89 | ] 90 | return [url.replace('lanzouo.com', d) for d in available_domains] 91 | 92 | def ignore_limits(self): 93 | """解除官方限制""" 94 | logger.warning("*** You have enabled the big file upload and filename disguise features ***") 95 | logger.warning("*** This means that you fully understand what may happen and still agree to take the risk ***") 96 | self._limit_mode = False 97 | 98 | def set_max_size(self, max_size=100) -> int: 99 | """设置单文件大小限制(会员用户可超过 100M)""" 100 | if max_size < 100: 101 | return LanZouCloud.FAILED 102 | self._max_size = max_size 103 | return LanZouCloud.SUCCESS 104 | 105 | def set_upload_delay(self, t_range: tuple) -> int: 106 | """设置上传大文件数据块时,相邻两次上传之间的延时,减小被封号的可能""" 107 | if 0 <= t_range[0] <= t_range[1]: 108 | self._upload_delay = t_range 109 | return LanZouCloud.SUCCESS 110 | return LanZouCloud.FAILED 111 | 112 | def login(self, username, passwd) -> int: 113 | """ 114 | 登录蓝奏云控制台[已弃用] 115 | 对某些用户可能有用 116 | """ 117 | login_data = {"task": "3", "setSessionId": "", "setToken": "", "setSig": "", 118 | "setScene": "", "uid": username, "pwd": passwd} 119 | phone_header = { 120 | "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"} 121 | html = self._get(self._account_url) 122 | if not html: 123 | return LanZouCloud.NETWORK_ERROR 124 | formhash = re.findall(r'name="formhash" value="(.+?)"', html.text) 125 | if not formhash: 126 | return LanZouCloud.FAILED 127 | login_data['formhash'] = formhash[0] 128 | html = self._post(self._mydisk_url, login_data, headers=phone_header) 129 | if not html: 130 | return LanZouCloud.NETWORK_ERROR 131 | try: 132 | if '成功' in html.json()['info']: 133 | self._cookies = html.cookies.get_dict() 134 | return LanZouCloud.SUCCESS 135 | else: 136 | return LanZouCloud.FAILED 137 | except ValueError: 138 | return LanZouCloud.FAILED 139 | 140 | def get_cookie(self) -> dict: 141 | """获取用户 Cookie""" 142 | return self._cookies 143 | 144 | def login_by_cookie(self, cookie: dict) -> int: 145 | """通过cookie登录""" 146 | self._session.cookies.update(cookie) 147 | html = self._get(self._account_url) 148 | if not html: 149 | return LanZouCloud.NETWORK_ERROR 150 | return LanZouCloud.FAILED if '网盘用户登录' in html.text else LanZouCloud.SUCCESS 151 | 152 | def logout(self) -> int: 153 | """注销""" 154 | html = self._get(self._account_url, params={'action': 'logout'}) 155 | if not html: 156 | return LanZouCloud.NETWORK_ERROR 157 | return LanZouCloud.SUCCESS if '退出系统成功' in html.text else LanZouCloud.FAILED 158 | 159 | def delete(self, fid, is_file=True) -> int: 160 | """把网盘的文件、无子文件夹的文件夹放到回收站""" 161 | post_data = {'task': 6, 'file_id': fid} if is_file else {'task': 3, 'folder_id': fid} 162 | result = self._post(self._doupload_url, post_data) 163 | if not result: 164 | return LanZouCloud.NETWORK_ERROR 165 | return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED 166 | 167 | def clean_rec(self) -> int: 168 | """清空回收站""" 169 | post_data = {'action': 'delete_all', 'task': 'delete_all'} 170 | html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) 171 | if not html: 172 | return LanZouCloud.NETWORK_ERROR 173 | post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', html.text)[0] # 设置表单 hash 174 | html = self._post(self._mydisk_url + '?item=recycle', post_data) 175 | if not html: 176 | return LanZouCloud.NETWORK_ERROR 177 | return LanZouCloud.SUCCESS if '清空回收站成功' in html.text else LanZouCloud.FAILED 178 | 179 | def get_rec_dir_list(self) -> FolderList: 180 | """获取回收站文件夹列表""" 181 | # 回收站中文件(夹)名只能显示前 17 个中文字符或者 34 个英文字符,如果这些字符相同,则在文件(夹)名后添加 (序号) ,以便区分 182 | html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) 183 | if not html: 184 | return FolderList() 185 | dirs = re.findall(r'folder_id=(\d+).+?> (.+?)\.{0,3}.*\n+.*(.+?).*\n.*(.+?)', 186 | html.text) 187 | all_dir_list = FolderList() # 文件夹信息列表 188 | dir_name_list = [] # 文件夹名列表d 189 | counter = 1 # 重复计数器 190 | for fid, name, size, time in dirs: 191 | if name in dir_name_list: # 文件夹名前 17 个中文或 34 个英文重复 192 | counter += 1 193 | name = f'{name}({counter})' 194 | else: 195 | counter = 1 196 | dir_name_list.append(name) 197 | all_dir_list.append(RecFolder(name, int(fid), size, time, None)) 198 | return all_dir_list 199 | 200 | def get_rec_file_list(self, folder_id=-1) -> FileList: 201 | """获取回收站文件列表""" 202 | if folder_id == -1: # 列出回收站根目录文件 203 | # 回收站文件夹中的文件也会显示在根目录 204 | html = self._get(self._mydisk_url, params={'item': 'recycle', 'action': 'files'}) 205 | if not html: 206 | return FileList() 207 | html = remove_notes(html.text) 208 | files = re.findall( 209 | r'fl_sel_ids[^\n]+value="(\d+)".+?filetype/(\w+)\.gif.+?/>\s?(.+?)(?:\.{3})?.+?([\d\-]+?)', 210 | html, re.DOTALL) 211 | file_list = FileList() 212 | file_name_list = [] 213 | counter = 1 214 | for fid, ftype, name, time in sorted(files, key=lambda x: x[2]): 215 | if not name.endswith(ftype): # 防止文件名太长导致丢失了文件后缀 216 | name = name + '.' + ftype 217 | 218 | if name in file_name_list: # 防止长文件名前 17:34 个字符相同重名 219 | counter += 1 220 | name = f'{name}({counter})' 221 | else: 222 | counter = 1 223 | file_name_list.append(name) 224 | file_list.append(RecFile(name, int(fid), ftype, size='', time=time)) 225 | return file_list 226 | else: # 列出回收站中文件夹内的文件,信息只有部分文件名和文件大小 227 | para = {'item': 'recycle', 'action': 'folder_restore', 'folder_id': folder_id} 228 | html = self._get(self._mydisk_url, params=para) 229 | if not html or '此文件夹没有包含文件' in html.text: 230 | return FileList() 231 | html = remove_notes(html.text) 232 | files = re.findall( 233 | r'com/(\d+?)".+?filetype/(\w+)\.gif.+?/> (.+?)(?:\.{3})? \((.+?)\)', 234 | html) 235 | file_list = FileList() 236 | file_name_list = [] 237 | counter = 1 238 | for fid, ftype, name, size in sorted(files, key=lambda x: x[2]): 239 | if not name.endswith(ftype): # 防止文件名太长丢失后缀 240 | name = name + '.' + ftype 241 | if name in file_name_list: 242 | counter += 1 243 | name = f'{name}({counter})' # 防止文件名太长且前17个字符重复 244 | else: 245 | counter = 1 246 | file_name_list.append(name) 247 | file_list.append(RecFile(name, int(fid), ftype, size=size, time='')) 248 | return file_list 249 | 250 | def get_rec_all(self): 251 | """获取整理后回收站的所有信息""" 252 | root_files = self.get_rec_file_list() # 回收站根目录文件列表 253 | folder_list = FolderList() # 保存整理后的文件夹列表 254 | for folder in self.get_rec_dir_list(): # 遍历所有子文件夹 255 | this_folder = RecFolder(folder.name, folder.id, folder.size, folder.time, FileList()) 256 | for file in self.get_rec_file_list(folder.id): # 文件夹内的文件属性: name,id,type,size 257 | if root_files.find_by_id(file.id): # 根目录存在同名文件 258 | file_time = root_files.pop_by_id(file.id).time # 从根目录删除, time 信息用来补充文件夹中的文件 259 | file = file._replace(time=file_time) # 不能直接更新 namedtuple, 需要 _replace 260 | this_folder.files.append(file) 261 | else: # 根目录没有同名文件(用户手动删了),文件还在文件夹中,只是根目录不显示,time 信息无法补全了 262 | file = file._replace(time=folder.time) # 那就设置时间为文件夹的创建时间 263 | this_folder.files.append(file) 264 | folder_list.append(this_folder) 265 | return root_files, folder_list 266 | 267 | def delete_rec(self, fid, is_file=True) -> int: 268 | """彻底删除回收站文件(夹)""" 269 | # 彻底删除后需要 1.5s 才能调用 get_rec_file() ,否则信息没有刷新,被删掉的文件似乎仍然 "存在" 270 | if is_file: 271 | para = {'item': 'recycle', 'action': 'file_delete_complete', 'file_id': fid} 272 | post_data = {'action': 'file_delete_complete', 'task': 'file_delete_complete', 'file_id': fid} 273 | else: 274 | para = {'item': 'recycle', 'action': 'folder_delete_complete', 'folder_id': fid} 275 | post_data = {'action': 'folder_delete_complete', 'task': 'folder_delete_complete', 'folder_id': fid} 276 | 277 | html = self._get(self._mydisk_url, params=para) 278 | if not html: 279 | return LanZouCloud.NETWORK_ERROR 280 | # 此处的 formhash 与 login 时不同,不要尝试精简这一步 281 | post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash 282 | html = self._post(self._mydisk_url + '?item=recycle', post_data) 283 | if not html: 284 | return LanZouCloud.NETWORK_ERROR 285 | return LanZouCloud.SUCCESS if '删除成功' in html.text else LanZouCloud.FAILED 286 | 287 | def delete_rec_multi(self, *, files=None, folders=None) -> int: 288 | """彻底删除回收站多个文件(夹) 289 | :param files 文件 id 列表 List[int] 290 | :param folders 文件夹 id 列表 List[int] 291 | """ 292 | if not files and not folders: 293 | return LanZouCloud.FAILED 294 | para = {'item': 'recycle', 'action': 'files'} 295 | post_data = {'action': 'files', 'task': 'delete_complete_recycle'} 296 | if folders: 297 | post_data['fd_sel_ids[]'] = folders 298 | if files: 299 | post_data['fl_sel_ids[]'] = files 300 | html = self._get(self._mydisk_url, params=para) 301 | if not html: 302 | return LanZouCloud.NETWORK_ERROR 303 | post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash 304 | html = self._post(self._mydisk_url + '?item=recycle', post_data) 305 | if not html: 306 | return LanZouCloud.NETWORK_ERROR 307 | return LanZouCloud.SUCCESS if '删除成功' in html.text else LanZouCloud.FAILED 308 | 309 | def recovery(self, fid, is_file=True) -> int: 310 | """从回收站恢复文件""" 311 | if is_file: 312 | para = {'item': 'recycle', 'action': 'file_restore', 'file_id': fid} 313 | post_data = {'action': 'file_restore', 'task': 'file_restore', 'file_id': fid} 314 | else: 315 | para = {'item': 'recycle', 'action': 'folder_restore', 'folder_id': fid} 316 | post_data = {'action': 'folder_restore', 'task': 'folder_restore', 'folder_id': fid} 317 | html = self._get(self._mydisk_url, params=para) 318 | if not html: 319 | return LanZouCloud.NETWORK_ERROR 320 | post_data['formhash'] = re.findall(r'name="formhash" value="(\w+?)"', html.text)[0] # 设置表单 hash 321 | html = self._post(self._mydisk_url + '?item=recycle', post_data) 322 | if not html: 323 | return LanZouCloud.NETWORK_ERROR 324 | return LanZouCloud.SUCCESS if '恢复成功' in html.text else LanZouCloud.FAILED 325 | 326 | def recovery_multi(self, *, files=None, folders=None) -> int: 327 | """从回收站恢复多个文件(夹)""" 328 | if not files and not folders: 329 | return LanZouCloud.FAILED 330 | para = {'item': 'recycle', 'action': 'files'} 331 | post_data = {'action': 'files', 'task': 'restore_recycle'} 332 | if folders: 333 | post_data['fd_sel_ids[]'] = folders 334 | if files: 335 | post_data['fl_sel_ids[]'] = files 336 | html = self._get(self._mydisk_url, params=para) 337 | if not html: 338 | return LanZouCloud.NETWORK_ERROR 339 | post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', html.text)[0] # 设置表单 hash 340 | html = self._post(self._mydisk_url + '?item=recycle', post_data) 341 | if not html: 342 | return LanZouCloud.NETWORK_ERROR 343 | return LanZouCloud.SUCCESS if '恢复成功' in html.text else LanZouCloud.FAILED 344 | 345 | def recovery_all(self) -> int: 346 | """从回收站恢复所有文件(夹)""" 347 | para = {'item': 'recycle', 'action': 'restore_all'} 348 | post_data = {'action': 'restore_all', 'task': 'restore_all'} 349 | first_page = self._get(self._mydisk_url, params=para) 350 | if not first_page: 351 | return LanZouCloud.NETWORK_ERROR 352 | post_data['formhash'] = re.findall(r'name="formhash" value="(.+?)"', first_page.text)[0] # 设置表单 hash 353 | second_page = self._post(self._mydisk_url + '?item=recycle', post_data) 354 | if not second_page: 355 | return LanZouCloud.NETWORK_ERROR 356 | return LanZouCloud.SUCCESS if '还原成功' in second_page.text else LanZouCloud.FAILED 357 | 358 | def get_file_list(self, folder_id=-1) -> FileList: 359 | """获取文件列表""" 360 | page = 1 361 | file_list = FileList() 362 | while True: 363 | post_data = {'task': 5, 'folder_id': folder_id, 'pg': page} 364 | resp = self._post(self._doupload_url, post_data) 365 | if not resp: # 网络异常,重试 366 | continue 367 | else: 368 | resp = resp.json() 369 | if resp["info"] == 0: 370 | break # 已经拿到了全部的文件信息 371 | else: 372 | page += 1 # 下一页 373 | # 文件信息处理 374 | for file in resp["text"]: 375 | file_list.append(File( 376 | id=int(file['id']), 377 | name=file['name_all'].replace("&", "&"), 378 | time=time_format(file['time']), # 上传时间 379 | size=file['size'].replace(",", ""), # 文件大小 380 | type=file['name_all'].split('.')[-1], # 文件类型 381 | downs=int(file['downs']), # 下载次数 382 | has_pwd=True if int(file['onof']) == 1 else False, # 是否存在提取码 383 | has_des=True if int(file['is_des']) == 1 else False # 是否存在描述 384 | )) 385 | return file_list 386 | 387 | def get_dir_list(self, folder_id=-1) -> FolderList: 388 | """获取子文件夹列表""" 389 | folder_list = FolderList() 390 | post_data = {'task': 47, 'folder_id': folder_id} 391 | resp = self._post(self._doupload_url, post_data) 392 | if not resp: 393 | return folder_list 394 | for folder in resp.json()['text']: 395 | folder_list.append( 396 | Folder( 397 | id=int(folder['fol_id']), 398 | name=folder['name'], 399 | has_pwd=True if int(folder['onof']) == 1 else False, 400 | desc=folder['folder_des'].strip('[]') 401 | )) 402 | return folder_list 403 | 404 | def clean_ghost_folders(self): 405 | """清除网盘中的幽灵文件夹""" 406 | 407 | # 可能有一些文件夹,网盘和回收站都看不见它,但是它确实存在,移动文件夹时才会显示 408 | # 如果不清理掉,不小心将文件移动进去就完蛋了 409 | def _clean(fid): 410 | for folder in self.get_dir_list(fid): 411 | real_folders.append(folder) 412 | _clean(folder.id) 413 | 414 | folder_with_ghost = self.get_move_folders() 415 | folder_with_ghost.pop_by_id(-1) # 忽视根目录 416 | real_folders = FolderList() 417 | _clean(-1) 418 | for folder in folder_with_ghost: 419 | if not real_folders.find_by_id(folder.id): 420 | logger.debug(f"Delete ghost folder: {folder.name} #{folder.id}") 421 | if self.delete(folder.id, False) != LanZouCloud.SUCCESS: 422 | return LanZouCloud.FAILED 423 | if self.delete_rec(folder.id, False) != LanZouCloud.SUCCESS: 424 | return LanZouCloud.FAILED 425 | return LanZouCloud.SUCCESS 426 | 427 | def get_full_path(self, folder_id=-1) -> FolderList: 428 | """获取文件夹完整路径""" 429 | path_list = FolderList() 430 | path_list.append(FolderId('LanZouCloud', -1)) 431 | post_data = {'task': 47, 'folder_id': folder_id} 432 | resp = self._post(self._doupload_url, post_data) 433 | if not resp: 434 | return path_list 435 | for folder in resp.json()['info']: 436 | if folder['folderid'] and folder['name']: # 有时会返回无效数据, 这两个字段中某个为 None 437 | path_list.append(FolderId(id=int(folder['folderid']), name=folder['name'])) 438 | return path_list 439 | 440 | def get_file_info_by_url(self, share_url, pwd='') -> FileDetail: 441 | """获取文件各种信息(包括下载直链) 442 | :param share_url: 文件分享链接 443 | :param pwd: 文件提取码(如果有的话) 444 | """ 445 | if not is_file_url(share_url): # 非文件链接返回错误 446 | return FileDetail(LanZouCloud.URL_INVALID, pwd=pwd, url=share_url) 447 | 448 | first_page = self._get(share_url) # 文件分享页面(第一页) 449 | if not first_page: 450 | return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url) 451 | 452 | if "acw_sc__v2" in first_page.text: 453 | # 在页面被过多访问或其他情况下,有时候会先返回一个加密的页面,其执行计算出一个acw_sc__v2后放入页面后再重新访问页面才能获得正常页面 454 | # 若该页面进行了js加密,则进行解密,计算acw_sc__v2,并加入cookie 455 | acw_sc__v2 = calc_acw_sc__v2(first_page.text) 456 | self._session.cookies.set("acw_sc__v2", acw_sc__v2) 457 | logger.debug(f"Set Cookie: acw_sc__v2={acw_sc__v2}") 458 | first_page = self._get(share_url) # 文件分享页面(第一页) 459 | if not first_page: 460 | return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url) 461 | 462 | first_page = remove_notes(first_page.text) # 去除网页里的注释 463 | if '文件取消' in first_page or '文件不存在' in first_page: 464 | return FileDetail(LanZouCloud.FILE_CANCELLED, pwd=pwd, url=share_url) 465 | 466 | # 这里获取下载直链 304 重定向前的链接 467 | try: 468 | if 'id="pwdload"' in first_page or 'id="passwddiv"' in first_page: # 文件设置了提取码时 469 | if len(pwd) == 0: 470 | return FileDetail(LanZouCloud.LACK_PASSWORD, pwd=pwd, url=share_url) # 没给提取码直接退出 471 | # data : 'action=downprocess&sign=AGZRbwEwU2IEDQU6BDRUaFc8DzxfMlRjCjTPlVkWzFSYFY7ATpWYw_c_c&p='+pwd, 472 | sign = re.search(r"sign=(\w+?)&", first_page).group(1) 473 | post_data = {'action': 'downprocess', 'sign': sign, 'p': pwd} 474 | link_info = self._post(self._host_url + '/ajaxm.php', post_data) # 保存了重定向前的链接信息和文件名 475 | second_page = self._get(share_url) # 再次请求文件分享页面,可以看见文件名,时间,大小等信息(第二页) 476 | if not link_info or not second_page.text: 477 | return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url) 478 | link_info = link_info.json() 479 | second_page = remove_notes(second_page.text) 480 | # 提取文件信息 481 | f_name = link_info['inf'].replace("*", "_") 482 | f_size = re.search(r'大小.+?(\d[\d.,]+\s?[BKM]?)<', second_page) 483 | f_size = f_size.group(1).replace(",", "") if f_size else '0 M' 484 | f_time = re.search(r'class="n_file_infos">(.+?)', second_page) 485 | f_time = time_format(f_time.group(1)) if f_time else time_format('0 小时前') 486 | f_desc = re.search(r'class="n_box_des">(.*?)', second_page) 487 | f_desc = f_desc.group(1) if f_desc else '' 488 | else: # 文件没有设置提取码时,文件信息都暴露在分享页面上 489 | para = re.search(r'(.+?) - 蓝奏云", first_page) or \ 492 | re.search(r'
([^<>]+?)
', first_page) or \ 493 | re.search(r'
(.+?)
', first_page) or \ 496 | re.search(r'
([^<>]+?)
', first_page) 497 | f_name = f_name.group(1).replace("*", "_") if f_name else "未匹配到文件名" 498 | # 匹配文件时间,文件没有时间信息就视为今天,统一表示为 2020-01-01 格式 499 | f_time = re.search(r'>(\d+\s?[秒天分小][钟时]?前|[昨前]天\s?[\d:]+?|\d+\s?天前|\d{4}-\d\d-\d\d)<', first_page) 500 | f_time = time_format(f_time.group(1)) if f_time else time_format('0 小时前') 501 | # 匹配文件大小 502 | f_size = re.search(r'大小.+?(\d[\d.,]+\s?[BKM]?)<', first_page) 503 | f_size = f_size.group(1).replace(",", "") if f_size else '0 M' 504 | f_desc = re.search(r'文件描述.+?
\n?\s*(.*?)\s*', first_page) 505 | f_desc = f_desc.group(1) if f_desc else '' 506 | first_page = self._get(self._host_url + para) 507 | if not first_page: 508 | return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, 509 | pwd=pwd, url=share_url) 510 | first_page = remove_notes(first_page.text) 511 | # 一般情况 sign 的值就在 data 里,有时放在变量后面 512 | sign = re.search(r"'sign':(.+?),", first_page).group(1) 513 | if len(sign) < 20: # 此时 sign 保存在变量里面, 变量名是 sign 匹配的字符 514 | sign = re.search(rf"var {sign}\s*=\s*'(.+?)';", first_page).group(1) 515 | post_data = {'action': 'downprocess', 'sign': sign, 'ves': 1} 516 | # 某些特殊情况 share_url 会出现 webpage 参数, post_data 需要更多参数 517 | # https://github.com/zaxtyson/LanZouCloud-API/issues/74 518 | # https://github.com/zaxtyson/LanZouCloud-API/issues/81 519 | if "?webpage=" in share_url: 520 | ajax_data = re.search(r"var ajaxdata\s*=\s*'(.+?)';", first_page).group(1) 521 | web_sign = re.search(r"var a?websigna?\s*=\s*'(.+?)';", first_page).group(1) 522 | web_sign_key = re.search(r"var c?websignkeyc?\s*=\s*'(.+?)';", first_page).group(1) 523 | post_data = {'action': 'downprocess', 'signs': ajax_data, 'sign': sign, 'ves': 1, 524 | 'websign': web_sign, 'websignkey': web_sign_key} 525 | link_info = self._post(self._host_url + '/ajaxm.php', post_data) 526 | if not link_info: 527 | return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, 528 | pwd=pwd, url=share_url) 529 | link_info = link_info.json() 530 | except AttributeError as e: # 正则匹配失败 531 | logger.error(e) 532 | return FileDetail(LanZouCloud.FAILED) 533 | 534 | # 这里开始获取文件直链 535 | if link_info['zt'] != 1: # 返回信息异常,无法获取直链 536 | return FileDetail(LanZouCloud.FAILED, name=f_name, time=f_time, size=f_size, desc=f_desc, pwd=pwd, 537 | url=share_url) 538 | 539 | fake_url = link_info['dom'] + '/file/' + link_info['url'] # 假直连,存在流量异常检测 540 | download_page = self._get(fake_url, allow_redirects=False) 541 | if not download_page: 542 | return FileDetail(LanZouCloud.NETWORK_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, 543 | pwd=pwd, url=share_url) 544 | download_page.encoding = 'utf-8' 545 | download_page_html = remove_notes(download_page.text) 546 | if '网络异常' not in download_page_html: # 没有遇到验证码 547 | direct_url = download_page.headers['Location'] # 重定向后的真直链 548 | else: # 遇到验证码,验证后才能获取下载直链 549 | try: 550 | file_token = re.findall("'file':'(.+?)'", download_page_html)[0] 551 | file_sign = re.findall("'sign':'(.+?)'", download_page_html)[0] 552 | check_api = 'https://vip.d0.baidupan.com/file/ajax.php' 553 | post_data = {'file': file_token, 'el': 2, 'sign': file_sign} 554 | sleep(2) # 这里必需等待2s, 否则直链返回 ?SignError 555 | resp = self._post(check_api, post_data) 556 | direct_url = resp.json()['url'] 557 | if not direct_url: 558 | return FileDetail(LanZouCloud.CAPTCHA_ERROR, name=f_name, time=f_time, size=f_size, desc=f_desc, 559 | pwd=pwd, url=share_url) 560 | except IndexError as e: 561 | logger.error(e) 562 | return FileDetail(LanZouCloud.FAILED) 563 | 564 | f_type = f_name.split('.')[-1] 565 | return FileDetail(LanZouCloud.SUCCESS, 566 | name=f_name, size=f_size, type=f_type, time=f_time, 567 | desc=f_desc, pwd=pwd, url=share_url, durl=direct_url) 568 | 569 | def get_file_info_by_id(self, file_id) -> FileDetail: 570 | """通过 id 获取文件信息""" 571 | info = self.get_share_info(file_id) 572 | if info.code != LanZouCloud.SUCCESS: 573 | return FileDetail(info.code) 574 | return self.get_file_info_by_url(info.url, info.pwd) 575 | 576 | def get_durl_by_url(self, share_url, pwd='') -> DirectUrlInfo: 577 | """通过分享链接获取下载直链""" 578 | file_info = self.get_file_info_by_url(share_url, pwd) 579 | if file_info.code != LanZouCloud.SUCCESS: 580 | return DirectUrlInfo(file_info.code, '', '') 581 | return DirectUrlInfo(LanZouCloud.SUCCESS, file_info.name, file_info.durl) 582 | 583 | def get_durl_by_id(self, file_id) -> DirectUrlInfo: 584 | """登录用户通过id获取直链""" 585 | info = self.get_share_info(file_id, is_file=True) # 能获取直链,一定是文件 586 | return self.get_durl_by_url(info.url, info.pwd) 587 | 588 | def get_share_info(self, fid, is_file=True) -> ShareInfo: 589 | """获取文件(夹)提取码、分享链接""" 590 | post_data = {'task': 22, 'file_id': fid} if is_file else {'task': 18, 'folder_id': fid} # 获取分享链接和密码用 591 | f_info = self._post(self._doupload_url, post_data) 592 | if not f_info: 593 | return ShareInfo(LanZouCloud.NETWORK_ERROR) 594 | else: 595 | f_info = f_info.json()['info'] 596 | 597 | # id 有效性校验 598 | if ('f_id' in f_info.keys() and f_info['f_id'] == 'i') or ('name' in f_info.keys() and not f_info['name']): 599 | return ShareInfo(LanZouCloud.ID_ERROR) 600 | 601 | # onof=1 时,存在有效的提取码; onof=0 时不存在提取码,但是 pwd 字段还是有一个无效的随机密码 602 | pwd = f_info['pwd'] if f_info['onof'] == '1' else '' 603 | if 'f_id' in f_info.keys(): # 说明返回的是文件的信息 604 | url = f_info['is_newd'] + '/' + f_info['f_id'] # 文件的分享链接需要拼凑 605 | file_info = self._post(self._doupload_url, {'task': 12, 'file_id': fid}) # 文件信息 606 | if not file_info: 607 | return ShareInfo(LanZouCloud.NETWORK_ERROR) 608 | name = file_info.json()['text'] # 无后缀的文件名(获得后缀又要发送请求,没有就没有吧,尽可能减少请求数量) 609 | desc = file_info.json()['info'] 610 | else: 611 | url = f_info['new_url'] # 文件夹的分享链接可以直接拿到 612 | name = f_info['name'] # 文件夹名 613 | desc = f_info['des'] # 文件夹描述 614 | return ShareInfo(LanZouCloud.SUCCESS, name=name, url=url, desc=desc, pwd=pwd) 615 | 616 | def set_passwd(self, fid, passwd='', is_file=True) -> int: 617 | """ 618 | 设置网盘文件(夹)的提取码, 现在非会员用户不允许关闭提取码 619 | id 无效或者 id 类型不对应仍然返回成功 :( 620 | 文件夹提取码长度 0-12 位 文件提取码 2-6 位 621 | """ 622 | 623 | passwd_status = 0 if passwd == '' else 1 # 是否开启密码 624 | if is_file: 625 | post_data = {"task": 23, "file_id": fid, "shows": passwd_status, "shownames": passwd} 626 | else: 627 | post_data = {"task": 16, "folder_id": fid, "shows": passwd_status, "shownames": passwd} 628 | result = self._post(self._doupload_url, post_data) 629 | if not result: 630 | return LanZouCloud.NETWORK_ERROR 631 | return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED 632 | 633 | def mkdir(self, parent_id, folder_name, desc='') -> int: 634 | """创建文件夹(同时设置描述)""" 635 | folder_name = folder_name.replace(' ', '_') # 文件夹名称不能包含空格 636 | folder_name = name_format(folder_name) # 去除非法字符 637 | folder_list = self.get_dir_list(parent_id) 638 | if folder_list.find_by_name(folder_name): # 如果文件夹已经存在,直接返回 id 639 | return folder_list.find_by_name(folder_name).id 640 | raw_folders = self.get_move_folders() 641 | post_data = {"task": 2, "parent_id": parent_id or -1, "folder_name": folder_name, 642 | "folder_description": desc} 643 | result = self._post(self._doupload_url, post_data) # 创建文件夹 644 | if not result or result.json()['zt'] != 1: 645 | logger.debug(f"Mkdir {folder_name} error, parent_id={parent_id}") 646 | return LanZouCloud.MKDIR_ERROR # 正常时返回 id 也是 int,为了方便判断是否成功,网络异常或者创建失败都返回相同错误码 647 | # 允许再不同路径创建同名文件夹, 移动时可通过 get_move_paths() 区分 648 | for folder in self.get_move_folders(): 649 | if not raw_folders.find_by_id(folder.id): 650 | logger.debug(f"Mkdir {folder_name} #{folder.id} in parent_id:{parent_id}") 651 | return folder.id 652 | logger.debug(f"Mkdir {folder_name} error, parent_id:{parent_id}") 653 | return LanZouCloud.MKDIR_ERROR 654 | 655 | def _set_dir_info(self, folder_id, folder_name, desc='') -> int: 656 | """重命名文件夹及其描述""" 657 | # 不能用于重命名文件,id 无效仍然返回成功 658 | folder_name = name_format(folder_name) 659 | post_data = {'task': 4, 'folder_id': folder_id, 'folder_name': folder_name, 'folder_description': desc} 660 | result = self._post(self._doupload_url, post_data) 661 | if not result: 662 | return LanZouCloud.NETWORK_ERROR 663 | return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED 664 | 665 | def rename_dir(self, folder_id, folder_name) -> int: 666 | """重命名文件夹""" 667 | # 重命名文件要开会员额 668 | info = self.get_share_info(folder_id, is_file=False) 669 | if info.code != LanZouCloud.SUCCESS: 670 | return info.code 671 | return self._set_dir_info(folder_id, folder_name, info.desc) 672 | 673 | def set_desc(self, fid, desc, is_file=True) -> int: 674 | """设置文件(夹)描述""" 675 | if is_file: 676 | # 文件描述一旦设置了值,就不能再设置为空 677 | post_data = {'task': 11, 'file_id': fid, 'desc': desc} 678 | result = self._post(self._doupload_url, post_data) 679 | if not result: 680 | return LanZouCloud.NETWORK_ERROR 681 | elif result.json()['zt'] != 1: 682 | return LanZouCloud.FAILED 683 | return LanZouCloud.SUCCESS 684 | else: 685 | # 文件夹描述可以置空 686 | info = self.get_share_info(fid, is_file=False) 687 | if info.code != LanZouCloud.SUCCESS: 688 | return info.code 689 | return self._set_dir_info(fid, info.name, desc) 690 | 691 | def rename_file(self, file_id, filename): 692 | """允许会员重命名文件(无法修后缀名)""" 693 | post_data = {'task': 46, 'file_id': file_id, 'file_name': name_format(filename), 'type': 2} 694 | result = self._post(self._doupload_url, post_data) 695 | if not result: 696 | return LanZouCloud.NETWORK_ERROR 697 | return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED 698 | 699 | def get_move_folders(self) -> FolderList: 700 | """获取全部文件夹 id-name 列表,用于移动文件至新的文件夹""" 701 | # 这里 file_id 可以为任意值,不会对结果产生影响 702 | result = FolderList() 703 | result.append(FolderId(name='LanZouCloud', id=-1)) 704 | resp = self._post(self._doupload_url, data={"task": 19, "file_id": -1}) 705 | if not resp or resp.json()['zt'] != 1: # 获取失败或者网络异常 706 | return result 707 | info = resp.json()['info'] or [] # 新注册用户无数据, info=None 708 | for folder in info: 709 | folder_id, folder_name = int(folder['folder_id']), folder['folder_name'] 710 | result.append(FolderId(folder_name, folder_id)) 711 | return result 712 | 713 | def get_move_paths(self) -> List[FolderList]: 714 | """获取所有文件夹的绝对路径(耗时长)""" 715 | # 官方 bug, 可能会返回一些已经被删除的"幽灵文件夹" 716 | result = [] 717 | root = FolderList() 718 | root.append(FolderId('LanZouCloud', -1)) 719 | result.append(root) 720 | resp = self._post(self._doupload_url, data={"task": 19, "file_id": -1}) 721 | if not resp or resp.json()['zt'] != 1: # 获取失败或者网络异常 722 | return result 723 | 724 | ex = ThreadPoolExecutor() # 线程数 min(32, os.cpu_count() + 4) 725 | id_list = [int(folder['folder_id']) for folder in resp.json()['info']] 726 | task_list = [ex.submit(self.get_full_path, fid) for fid in id_list] 727 | for task in as_completed(task_list): 728 | result.append(task.result()) 729 | return sorted(result) 730 | 731 | def move_file(self, file_id, folder_id=-1) -> int: 732 | """移动文件到指定文件夹""" 733 | # 移动回收站文件也返回成功(实际上行不通) (+_+)? 734 | post_data = {'task': 20, 'file_id': file_id, 'folder_id': folder_id} 735 | result = self._post(self._doupload_url, post_data) 736 | logger.debug(f"Move file file_id:{file_id} to folder_id:{folder_id}") 737 | if not result: 738 | return LanZouCloud.NETWORK_ERROR 739 | return LanZouCloud.SUCCESS if result.json()['zt'] == 1 else LanZouCloud.FAILED 740 | 741 | def move_folder(self, folder_id, parent_folder_id=-1) -> int: 742 | """移动文件夹(官方并没有直接支持此功能)""" 743 | if folder_id == parent_folder_id or parent_folder_id < -1: 744 | return LanZouCloud.FAILED # 禁止移动文件夹到自身,禁止移动到 -2 这样的文件夹(文件还在,但是从此不可见) 745 | 746 | folder = self.get_move_folders().find_by_id(folder_id) 747 | if not folder: 748 | logger.debug(f"Not found folder id:{folder_id}") 749 | return LanZouCloud.FAILED 750 | 751 | if self.get_dir_list(folder_id): 752 | logger.debug(f"Found subdirectory in folder={folder}") 753 | return LanZouCloud.FAILED # 递归操作可能会产生大量请求,这里只移动单层文件夹 754 | 755 | info = self.get_share_info(folder_id, False) 756 | new_folder_id = self.mkdir(parent_folder_id, folder.name, info.desc) # 在目标文件夹下创建同名文件夹 757 | 758 | if new_folder_id == LanZouCloud.MKDIR_ERROR: 759 | return LanZouCloud.FAILED 760 | elif new_folder_id == folder_id: # 移动文件夹到同一目录 761 | return LanZouCloud.FAILED 762 | 763 | self.set_passwd(new_folder_id, info.pwd, False) # 保持密码相同 764 | ex = ThreadPoolExecutor() 765 | task_list = [ex.submit(self.move_file, file.id, new_folder_id) for file in self.get_file_list(folder_id)] 766 | for task in as_completed(task_list): 767 | if task.result() != LanZouCloud.SUCCESS: 768 | return LanZouCloud.FAILED 769 | self.delete(folder_id, False) # 全部移动完成后删除原文件夹 770 | self.delete_rec(folder_id, False) 771 | return LanZouCloud.SUCCESS 772 | 773 | def _upload_small_file(self, file_path, folder_id=-1, *, callback=None, uploaded_handler=None) -> int: 774 | """绕过格式限制上传不超过 max_size 的文件""" 775 | if not os.path.isfile(file_path): 776 | return LanZouCloud.PATH_ERROR 777 | 778 | need_delete = False # 上传完成是否删除 779 | if not is_name_valid(os.path.basename(file_path)): # 不允许上传的格式 780 | if self._limit_mode: # 不允许绕过官方限制 781 | return LanZouCloud.OFFICIAL_LIMITED 782 | file_path = let_me_upload(file_path) # 添加了报尾的新文件 783 | need_delete = True 784 | 785 | # 文件已经存在同名文件就删除 786 | filename = name_format(os.path.basename(file_path)) 787 | file_list = self.get_file_list(folder_id) 788 | if file_list.find_by_name(filename): 789 | self.delete(file_list.find_by_name(filename).id) 790 | logger.debug(f'Upload file_path:{file_path} to folder_id:{folder_id}') 791 | 792 | file = open(file_path, 'rb') 793 | post_data = { 794 | "task": "1", 795 | "folder_id": str(folder_id), 796 | "id": "WU_FILE_0", 797 | "name": filename, 798 | "upload_file": (filename, file, 'application/octet-stream') 799 | } 800 | 801 | post_data = MultipartEncoder(post_data) 802 | tmp_header = self._headers.copy() 803 | tmp_header['Content-Type'] = post_data.content_type 804 | 805 | # MultipartEncoderMonitor 每上传 8129 bytes数据调用一次回调函数,问题根源是 httplib 库 806 | # issue : https://github.com/requests/toolbelt/issues/75 807 | # 上传完成后,回调函数会被错误的多调用一次(强迫症受不了)。因此,下面重新封装了回调函数,修改了接受的参数,并阻断了多余的一次调用 808 | self._upload_finished_flag = False # 上传完成的标志 809 | 810 | def _call_back(read_monitor): 811 | if callback is not None: 812 | if not self._upload_finished_flag: 813 | callback(filename, read_monitor.len, read_monitor.bytes_read) 814 | if read_monitor.len == read_monitor.bytes_read: 815 | self._upload_finished_flag = True 816 | 817 | monitor = MultipartEncoderMonitor(post_data, _call_back) 818 | result = self._post('https://pc.woozooo.com/fileup.php', data=monitor, headers=tmp_header, timeout=3600) 819 | if not result: # 网络异常 820 | file.close() 821 | return LanZouCloud.NETWORK_ERROR 822 | else: 823 | result = result.json() 824 | if result["zt"] != 1: 825 | logger.debug(f'Upload failed: result={result}') 826 | file.close() 827 | return LanZouCloud.FAILED # 上传失败 828 | 829 | if uploaded_handler is not None: 830 | file_id = int(result["text"][0]["id"]) 831 | uploaded_handler(file_id, is_file=True) # 对已经上传的文件再进一步处理 832 | 833 | if need_delete: 834 | os.remove(file_path) 835 | 836 | file.close() 837 | return LanZouCloud.SUCCESS 838 | 839 | def _upload_big_file(self, file_path, dir_id, *, callback=None, uploaded_handler=None): 840 | """上传大文件, 且使得回调函数只显示一个文件""" 841 | if self._limit_mode: # 不允许绕过官方限制 842 | return LanZouCloud.OFFICIAL_LIMITED 843 | 844 | file_size = os.path.getsize(file_path) # 原始文件的字节大小 845 | file_name = os.path.basename(file_path) 846 | tmp_dir = os.path.dirname(file_path) + os.sep + '__' + '.'.join(file_name.split('.')[:-1]) # 临时文件保存路径 847 | record_file = tmp_dir + os.sep + file_name + '.record' # 记录文件,大文件没有完全上传前保留,用于支持续传 848 | uploaded_size = 0 # 记录已上传字节数,用于回调函数 849 | 850 | if not os.path.exists(tmp_dir): 851 | os.makedirs(tmp_dir) 852 | if not os.path.exists(record_file): # 初始化记录文件 853 | info = {'name': file_name, 'size': file_size, 'uploaded': 0, 'parts': []} 854 | with open(record_file, 'wb') as f: 855 | pickle.dump(info, f, protocol=4) 856 | else: 857 | with open(record_file, 'rb') as f: 858 | info = pickle.load(f) 859 | uploaded_size = info['uploaded'] # 读取已经上传的大小 860 | logger.debug(f"Find upload record: {uploaded_size}/{file_size}") 861 | 862 | def _callback(name, t_size, now_size): # 重新封装回调函数,隐藏数据块上传细节 863 | nonlocal uploaded_size 864 | if callback is not None: 865 | # MultipartEncoder 以后,文件数据流比原文件略大几百字节, now_size 略大于 file_size 866 | now_size = uploaded_size + now_size 867 | now_size = now_size if now_size < file_size else file_size # 99.99% -> 100.00% 868 | callback(file_name, file_size, now_size) 869 | 870 | def _close_pwd(fid, is_file): # 数据块上传后默认关闭提取码 871 | self.set_passwd(fid) 872 | 873 | while uploaded_size < file_size: 874 | data_size, data_path = big_file_split(file_path, self._max_size, start_byte=uploaded_size) 875 | code = self._upload_small_file(data_path, dir_id, callback=_callback, uploaded_handler=_close_pwd) 876 | if code == LanZouCloud.SUCCESS: 877 | uploaded_size += data_size # 更新已上传的总字节大小 878 | info['uploaded'] = uploaded_size 879 | info['parts'].append(os.path.basename(data_path)) # 记录已上传的文件名 880 | with open(record_file, 'wb') as f: 881 | logger.debug(f"Update record file: {uploaded_size}/{file_size}") 882 | pickle.dump(info, f, protocol=4) 883 | else: 884 | logger.debug(f"Upload data file failed: data_path={data_path}") 885 | return LanZouCloud.FAILED 886 | os.remove(data_path) # 删除临时数据块 887 | min_s, max_s = self._upload_delay # 设置两次上传间的延时,减小封号可能性 888 | sleep_time = uniform(min_s, max_s) 889 | logger.debug(f"Sleeping, Upload task will resume after {sleep_time:.2f}s...") 890 | sleep(sleep_time) 891 | 892 | # 全部数据块上传完成 893 | record_name = list(file_name.replace('.', '')) # 记录文件名也打乱 894 | shuffle(record_name) 895 | record_name = name_format(''.join(record_name)) + '.txt' 896 | record_file_new = tmp_dir + os.sep + record_name 897 | os.rename(record_file, record_file_new) 898 | code = self._upload_small_file(record_file_new, dir_id, uploaded_handler=_close_pwd) # 上传记录文件 899 | if code != LanZouCloud.SUCCESS: 900 | logger.debug(f"Upload record file failed: {record_file_new}") 901 | return LanZouCloud.FAILED 902 | # 记录文件上传成功,删除临时文件 903 | shutil.rmtree(tmp_dir) 904 | logger.debug(f"Upload finished, Delete tmp folder:{tmp_dir}") 905 | return LanZouCloud.SUCCESS 906 | 907 | def upload_file(self, file_path, folder_id=-1, *, callback=None, uploaded_handler=None) -> int: 908 | """解除限制上传文件 909 | :param callback 用于显示上传进度的回调函数 910 | def callback(file_name, total_size, now_size): 911 | print(f"\r文件名:{file_name}, 进度: {now_size}/{total_size}") 912 | ... 913 | 914 | :param uploaded_handler 用于进一步处理上传完成后的文件, 对大文件而已是处理文件夹(数据块默认关闭密码) 915 | def uploaded_handler(fid, is_file): 916 | if is_file: 917 | self.set_desc(fid, '...', is_file=True) 918 | ... 919 | """ 920 | if not os.path.isfile(file_path): 921 | return LanZouCloud.PATH_ERROR 922 | 923 | # 单个文件不超过 max_size 直接上传 924 | if os.path.getsize(file_path) <= self._max_size * 1048576: 925 | return self._upload_small_file(file_path, folder_id, callback=callback, uploaded_handler=uploaded_handler) 926 | 927 | # 上传超过 max_size 的文件 928 | if self._limit_mode: 929 | return LanZouCloud.OFFICIAL_LIMITED 930 | 931 | folder_name = os.path.basename(file_path) # 保存分段文件的文件夹名 932 | dir_id = self.mkdir(folder_id, folder_name, 'Big File') 933 | if dir_id == LanZouCloud.MKDIR_ERROR: 934 | return LanZouCloud.MKDIR_ERROR # 创建文件夹失败就退出 935 | 936 | if uploaded_handler is not None: 937 | uploaded_handler(dir_id, is_file=False) 938 | return self._upload_big_file(file_path, dir_id, callback=callback, uploaded_handler=uploaded_handler) 939 | 940 | def upload_dir(self, dir_path, folder_id=-1, *, callback=None, failed_callback=None, uploaded_handler=None): 941 | """批量上传文件夹中的文件(不会递归上传子文件夹) 942 | :param folder_id: 网盘文件夹 id 943 | :param dir_path: 文件夹路径 944 | :param callback 用于显示进度 945 | def callback(file_name, total_size, now_size): 946 | print(f"\r文件名:{file_name}, 进度: {now_size}/{total_size}") 947 | ... 948 | :param failed_callback 用于处理上传失败文件的回调函数 949 | def failed_callback(code, file_name): 950 | print(f"上传失败, 文件名: {file_name}, 错误码: {code}") 951 | ... 952 | :param uploaded_handler 用于进一步处理上传完成后的文件, 对大文件而已是处理文件夹(数据块默认关闭密码) 953 | def uploaded_handler(fid, is_file): 954 | if is_file: 955 | self.set_desc(fid, '...', is_file=True) 956 | ... 957 | """ 958 | if not os.path.isdir(dir_path): 959 | return LanZouCloud.PATH_ERROR 960 | 961 | dir_name = dir_path.split(os.sep)[-1] 962 | dir_id = self.mkdir(folder_id, dir_name, '批量上传') 963 | if dir_id == LanZouCloud.MKDIR_ERROR: 964 | return LanZouCloud.MKDIR_ERROR 965 | 966 | for filename in os.listdir(dir_path): 967 | file_path = dir_path + os.sep + filename 968 | if not os.path.isfile(file_path): 969 | continue # 跳过子文件夹 970 | code = self.upload_file(file_path, dir_id, callback=callback, uploaded_handler=uploaded_handler) 971 | if code != LanZouCloud.SUCCESS: 972 | if failed_callback is not None: 973 | failed_callback(code, filename) 974 | return LanZouCloud.SUCCESS 975 | 976 | def down_file_by_url(self, share_url, pwd='', save_path='./Download', *, callback=None, overwrite=False, 977 | downloaded_handler=None) -> int: 978 | """通过分享链接下载文件(需提取码) 979 | :param callback 用于显示下载进度 callback(file_name, total_size, now_size) 980 | :param overwrite 文件已存在时是否强制覆盖 981 | :param downloaded_handler 下载完成后进一步处理文件的回调函数 downloaded_handle(file_path) 982 | """ 983 | if not is_file_url(share_url): 984 | return LanZouCloud.URL_INVALID 985 | if not os.path.exists(save_path): 986 | os.makedirs(save_path) 987 | 988 | info = self.get_durl_by_url(share_url, pwd) 989 | logger.debug(f'File direct url info: {info}') 990 | if info.code != LanZouCloud.SUCCESS: 991 | return info.code 992 | 993 | resp = self._get(info.durl, stream=True) 994 | if not resp: 995 | return LanZouCloud.FAILED 996 | 997 | # 如果本地存在同名文件且设置了 overwrite, 则覆盖原文件 998 | # 否则修改下载文件路径, 自动在文件名后加序号 999 | file_path = save_path + os.sep + info.name 1000 | if os.path.exists(file_path): 1001 | if overwrite: 1002 | logger.debug(f"Overwrite file {file_path}") 1003 | os.remove(file_path) # 删除旧文件 1004 | else: # 自动重命名文件 1005 | file_path = auto_rename(file_path) 1006 | logger.debug(f"File has already exists, auto rename to {file_path}") 1007 | 1008 | tmp_file_path = file_path + '.download' # 正在下载中的文件名 1009 | logger.debug(f'Save file to {tmp_file_path}') 1010 | 1011 | # 对于 txt 文件, 可能出现没有 Content-Length 的情况 1012 | # 此时文件需要下载一次才会出现 Content-Length 1013 | # 这时候我们先读取一点数据, 再尝试获取一次, 通常只需读取 1 字节数据 1014 | content_length = resp.headers.get('Content-Length', None) 1015 | if not content_length: 1016 | data_iter = resp.iter_content(chunk_size=1) 1017 | max_retries = 5 # 5 次拿不到就算了 1018 | while not content_length and max_retries > 0: 1019 | max_retries -= 1 1020 | logger.warning("Not found Content-Length in response headers") 1021 | logger.debug("Read 1 byte from stream...") 1022 | try: 1023 | next(data_iter) # 读取一个字节 1024 | except StopIteration: 1025 | logger.debug("Please wait for a moment before downloading") 1026 | return LanZouCloud.FAILED 1027 | resp_ = self._get(info.durl, stream=True) # 再请求一次试试 1028 | if not resp_: 1029 | return LanZouCloud.FAILED 1030 | content_length = resp_.headers.get('Content-Length', None) 1031 | logger.debug(f"Content-Length: {content_length}") 1032 | 1033 | if not content_length: 1034 | return LanZouCloud.FAILED # 应该不会出现这种情况 1035 | 1036 | # 支持断点续传下载 1037 | now_size = 0 1038 | if os.path.exists(tmp_file_path): 1039 | now_size = os.path.getsize(tmp_file_path) # 本地已经下载的文件大小 1040 | headers = {**self._headers, 'Range': 'bytes=%d-' % now_size} 1041 | resp = self._get(info.durl, stream=True, headers=headers) 1042 | 1043 | if resp is None: # 网络异常 1044 | return LanZouCloud.FAILED 1045 | if resp.status_code == 416: # 已经下载完成 1046 | return LanZouCloud.SUCCESS 1047 | 1048 | with open(tmp_file_path, "ab") as f: 1049 | file_name = os.path.basename(file_path) 1050 | for chunk in resp.iter_content(4096): 1051 | if chunk: 1052 | f.write(chunk) 1053 | f.flush() 1054 | now_size += len(chunk) 1055 | if callback is not None: 1056 | callback(file_name, int(content_length), now_size) 1057 | 1058 | # 文件下载完成后, 检查文件尾部 512 字节数据 1059 | # 绕过官方限制上传时, API 会隐藏文件真实信息到文件尾部 1060 | # 这里尝试提取隐藏信息, 并截断文件尾部数据 1061 | os.rename(tmp_file_path, file_path) # 下载完成,改回正常文件名 1062 | if os.path.getsize(file_path) > 512: # 文件大于 512 bytes 就检查一下 1063 | file_info = None 1064 | is_protocol_3 = False 1065 | with open(file_path, 'rb') as f: 1066 | f.seek(-512, os.SEEK_END) 1067 | last_512_bytes = f.read() 1068 | file_info = un_serialize(last_512_bytes) 1069 | # Python3.7 序列化时默认使用 pickle 第三版协议, 1070 | # 导致计算时文件尾部多写了 5 字节, 应该都是用3.8, 保险期起见处理一下 1071 | if not file_info: 1072 | is_protocol_3 = True 1073 | f.seek(-517, os.SEEK_END) 1074 | last_517_bytes = f.read() 1075 | file_info = un_serialize(last_517_bytes) 1076 | 1077 | # 大文件的记录文件也可以反序列化出 name,但是没有 padding 字段 1078 | if file_info is not None and 'padding' in file_info: 1079 | real_name = file_info['name'] # 解除伪装的真实文件名 1080 | logger.debug(f"Find meta info: real_name={real_name}") 1081 | real_path = save_path + os.sep + real_name 1082 | # 如果存在同名文件且设置了 overwrite, 删掉原文件 1083 | if overwrite and os.path.exists(real_path): 1084 | os.remove(real_path) 1085 | # 自动重命名, 文件存在就会加个序号 1086 | new_file_path = auto_rename(real_path) 1087 | os.rename(file_path, new_file_path) 1088 | # 截断最后 512 字节隐藏信息, 还原文件 1089 | with open(new_file_path, 'rb+') as f: 1090 | truncate_size = 517 if is_protocol_3 else 512 1091 | logger.debug(f"Truncate last {truncate_size} bytes of file") 1092 | f.seek(-truncate_size, os.SEEK_END) 1093 | f.truncate() 1094 | file_path = new_file_path # 保存文件重命名后真实路径 1095 | 1096 | # 如果设置了下载完成的回调函数, 调用之 1097 | if downloaded_handler is not None: 1098 | downloaded_handler(os.path.abspath(file_path)) 1099 | return LanZouCloud.SUCCESS 1100 | 1101 | def down_file_by_id(self, fid, save_path='./Download', *, callback=None, overwrite=False, 1102 | downloaded_handler=None) -> int: 1103 | """登录用户通过id下载文件(无需提取码)""" 1104 | info = self.get_share_info(fid, is_file=True) 1105 | if info.code != LanZouCloud.SUCCESS: 1106 | return info.code 1107 | return self.down_file_by_url(info.url, info.pwd, save_path, callback=callback, overwrite=overwrite, 1108 | downloaded_handler=downloaded_handler) 1109 | 1110 | def get_folder_info_by_url(self, share_url, dir_pwd='') -> FolderDetail: 1111 | """获取文件夹里所有文件的信息""" 1112 | if is_file_url(share_url): 1113 | return FolderDetail(LanZouCloud.URL_INVALID) 1114 | try: 1115 | html = self._get(share_url, headers=self._headers).text 1116 | except requests.RequestException: 1117 | return FolderDetail(LanZouCloud.NETWORK_ERROR) 1118 | if '文件不存在' in html or '文件取消' in html: 1119 | return FolderDetail(LanZouCloud.FILE_CANCELLED) 1120 | # 要求输入密码, 用户描述中可能带有"输入密码",所以不用这个字符串判断 1121 | if ('id="pwdload"' in html or 'id="passwddiv"' in html) and len(dir_pwd) == 0: 1122 | return FolderDetail(LanZouCloud.LACK_PASSWORD) 1123 | 1124 | if "acw_sc__v2" in html: 1125 | # 在页面被过多访问或其他情况下,有时候会先返回一个加密的页面,其执行计算出一个acw_sc__v2后放入页面后再重新访问页面才能获得正常页面 1126 | # 若该页面进行了js加密,则进行解密,计算acw_sc__v2,并加入cookie 1127 | acw_sc__v2 = calc_acw_sc__v2(html) 1128 | self._session.cookies.set("acw_sc__v2", acw_sc__v2) 1129 | logger.debug(f"Set Cookie: acw_sc__v2={acw_sc__v2}") 1130 | html = self._get(share_url).text # 文件分享页面(第一页) 1131 | 1132 | try: 1133 | # 获取文件需要的参数 1134 | html = remove_notes(html) 1135 | lx = re.findall(r"'lx':'?(\d)'?,", html)[0] 1136 | t = re.findall(r"var [0-9a-z]{6} = '(\d{10})';", html)[0] 1137 | k = re.findall(r"var [0-9a-z]{6} = '([0-9a-z]{15,})';", html)[0] 1138 | # 文件夹的信息 1139 | folder_id = re.findall(r"'fid':'?(\d+)'?,", html)[0] 1140 | folder_name = re.findall(r"var.+?='(.+?)';\n.+document.title", html) or \ 1141 | re.findall(r'
(.+?)
', html) 1142 | folder_name = folder_name[0] 1143 | 1144 | folder_time = re.findall(r'class="rets">([\d\-]+?)(.+?)', html) or \ 1147 | re.findall(r'
(.+?)', html) 1148 | folder_desc = folder_desc[0] if folder_desc else "" 1149 | except IndexError: 1150 | return FolderDetail(LanZouCloud.FAILED) 1151 | 1152 | # 提取子文件夹信息(vip用户分享的文件夹可以递归包含子文件夹) 1153 | sub_folders = FolderList() 1154 | # 文件夹描述放在 filesize 一栏, 迷惑行为 1155 | all_sub_folders = re.findall( 1156 | r'mbxfolder">(.+?)
(.*?)
', html) 1157 | for url, name, desc in all_sub_folders: 1158 | url = self._host_url + url 1159 | time_str = datetime.today().strftime('%Y-%m-%d') # 网页没有时间信息, 设置为今天 1160 | sub_folders.append(FolderInfo(name=name, desc=desc, url=url, time=time_str, pwd=dir_pwd)) 1161 | 1162 | # 提取改文件夹下全部文件 1163 | page = 1 1164 | files = FileList() 1165 | while True: 1166 | if page >= 2: # 连续的请求需要稍等一下 1167 | sleep(0.6) 1168 | try: 1169 | logger.debug(f"Parse page {page}...") 1170 | post_data = {'lx': lx, 'pg': page, 'k': k, 't': t, 'fid': folder_id, 'pwd': dir_pwd} 1171 | resp = self._post(self._host_url + '/filemoreajax.php', data=post_data, headers=self._headers).json() 1172 | except (requests.RequestException, AttributeError): 1173 | return FolderDetail(LanZouCloud.NETWORK_ERROR) 1174 | if resp['zt'] == 1: # 成功获取一页文件信息 1175 | for f in resp["text"]: 1176 | files.append(FileInFolder( 1177 | name=f["name_all"], # 文件名 1178 | time=time_format(f["time"]), # 上传时间 1179 | size=f["size"], # 文件大小 1180 | type=f["name_all"].split('.')[-1], # 文件格式 1181 | url=self._host_url + "/" + f["id"] # 文件分享链接 1182 | )) 1183 | page += 1 # 下一页 1184 | continue 1185 | elif resp['zt'] == 2: # 已经拿到全部的文件信息 1186 | break 1187 | elif resp['zt'] == 3: # 提取码错误 1188 | return FolderDetail(LanZouCloud.PASSWORD_ERROR) 1189 | elif resp["zt"] == 4: 1190 | continue 1191 | else: 1192 | return FolderDetail(LanZouCloud.FAILED) # 其它未知错误 1193 | 1194 | # 通过文件的时间信息补全文件夹的年份(如果有文件的话) 1195 | if files: # 最后一个文件上传时间最早,文件夹的创建年份与其相同 1196 | folder_time = files[-1].time.split('-')[0] + '-' + folder_time 1197 | else: # 可恶,没有文件,日期就设置为今年吧 1198 | folder_time = datetime.today().strftime('%Y-%m-%d') 1199 | 1200 | this_folder = FolderInfo(folder_name, folder_id, dir_pwd, folder_time, folder_desc, share_url) 1201 | return FolderDetail(LanZouCloud.SUCCESS, this_folder, files, sub_folders) 1202 | 1203 | def get_folder_info_by_id(self, folder_id): 1204 | """通过 id 获取文件夹及内部文件信息""" 1205 | info = self.get_share_info(folder_id, is_file=False) 1206 | if info.code != LanZouCloud.SUCCESS: 1207 | return FolderDetail(info.code) 1208 | return self.get_folder_info_by_url(info.url, info.pwd) 1209 | 1210 | def _check_big_file(self, file_list): 1211 | """检查文件列表,判断是否为大文件分段数据""" 1212 | txt_files = file_list.filter(lambda f: f.name.endswith('.txt') and 'M' not in f.size) 1213 | if txt_files and len(txt_files) == 1: # 文件夹里有且仅有一个 txt, 很有可能是保存大文件的文件夹 1214 | try: 1215 | info = self.get_durl_by_url(txt_files[0].url) 1216 | except AttributeError: 1217 | info = self.get_durl_by_id(txt_files[0].id) 1218 | if info.code != LanZouCloud.SUCCESS: 1219 | logger.debug(f"Big file checking: Failed") 1220 | return None 1221 | resp = self._get(info.durl) 1222 | # 这里无需知道 txt 文件的 Content-Length, 全部读取即可 1223 | info = un_serialize(resp.content) if resp else None 1224 | if info is not None: # 确认是大文件 1225 | name, size, *_, parts = info.values() # 真实文件名, 文件字节大小, (其它数据),分段数据文件名(有序) 1226 | file_list = [file_list.find_by_name(p) for p in parts] 1227 | if all(file_list): # 分段数据完整 1228 | logger.debug(f"Big file checking: PASS , name={name}, size={size}") 1229 | return name, size, file_list 1230 | logger.debug(f"Big file checking: Failed, Missing some data") 1231 | logger.debug(f"Big file checking: Failed") 1232 | return None 1233 | 1234 | def _down_big_file(self, name, total_size, file_list, save_path, *, callback=None, overwrite=False, 1235 | downloaded_handler=None): 1236 | """下载分段数据到一个文件,回调函数只显示一个文件 1237 | 支持大文件下载续传,下载完成后重复下载不会执行覆盖操作,直接返回状态码 SUCCESS 1238 | """ 1239 | if not os.path.exists(save_path): 1240 | os.makedirs(save_path) 1241 | 1242 | big_file = save_path + os.sep + name 1243 | record_file = big_file + '.record' 1244 | 1245 | if os.path.exists(big_file) and not os.path.exists(record_file): 1246 | if overwrite: 1247 | os.remove(big_file) # 删除原文件 1248 | else: 1249 | big_file = auto_rename(big_file) 1250 | record_file = big_file + '.record' 1251 | 1252 | if not os.path.exists(record_file): # 初始化记录文件 1253 | info = {'last_ending': 0, 'finished': []} # 记录上一个数据块结尾地址和已经下载的数据块 1254 | with open(record_file, 'wb') as rf: 1255 | pickle.dump(info, rf, protocol=4) 1256 | else: # 读取记录文件,下载续传 1257 | with open(record_file, 'rb') as rf: 1258 | info = pickle.load(rf) 1259 | file_list = [f for f in file_list if f.name not in info['finished']] # 排除已下载的数据块 1260 | logger.debug(f"Find download record file: {info}") 1261 | 1262 | file_name = os.path.basename(big_file) 1263 | with open(big_file, 'ab') as bf: 1264 | for file in file_list: 1265 | try: 1266 | durl_info = self.get_durl_by_url(file.url) # 分段文件无密码 1267 | except AttributeError: 1268 | durl_info = self.get_durl_by_id(file.id) 1269 | if durl_info.code != LanZouCloud.SUCCESS: 1270 | logger.debug(f"Can't get direct url: {file}") 1271 | return durl_info.code 1272 | # 准备向大文件写入数据 1273 | file_size_now = os.path.getsize(big_file) 1274 | down_start_byte = file_size_now - info['last_ending'] # 当前数据块上次下载中断的位置 1275 | headers = {**self._headers, 'Range': 'bytes=%d-' % down_start_byte} 1276 | logger.debug(f"Download {file.name}, Range: {down_start_byte}-") 1277 | resp = self._get(durl_info.durl, stream=True, headers=headers) 1278 | 1279 | if resp is None: # 网络错误, 没有响应数据 1280 | return LanZouCloud.FAILED 1281 | if resp.status_code == 416: # 下载完成后重复下载导致 Range 越界, 服务器返回 416 1282 | logger.debug(f"File {file_name} has already downloaded.") 1283 | os.remove(record_file) # 删除记录文件 1284 | return LanZouCloud.SUCCESS 1285 | 1286 | try: 1287 | for chunk in resp.iter_content(4096): 1288 | if chunk: 1289 | file_size_now += len(chunk) 1290 | bf.write(chunk) 1291 | bf.flush() # 确保缓冲区立即写入文件,否则下一次写入时获取的文件大小会有偏差 1292 | if callback: 1293 | callback(file_name, total_size, file_size_now) 1294 | # 一块数据写入完成,更新记录文件 1295 | info['finished'].append(file.name) 1296 | finally: 1297 | info['last_ending'] = file_size_now 1298 | with open(record_file, 'wb') as rf: 1299 | pickle.dump(info, rf, protocol=4) 1300 | logger.debug(f"Update download record info: {info}") 1301 | # 全部数据块下载完成, 记录文件可以删除 1302 | logger.debug(f"Delete download record file: {record_file}") 1303 | os.remove(record_file) 1304 | 1305 | if downloaded_handler is not None: 1306 | downloaded_handler(os.path.abspath(big_file)) 1307 | return LanZouCloud.SUCCESS 1308 | 1309 | def down_dir_by_url(self, share_url, dir_pwd='', save_path='./Download', *, callback=None, mkdir=True, 1310 | overwrite=False, recursive=False, 1311 | failed_callback=None, downloaded_handler=None) -> int: 1312 | """通过分享链接下载文件夹 1313 | :param overwrite: 下载时是否覆盖原文件, 对大文件也生效 1314 | :param save_path 文件夹保存路径 1315 | :param mkdir 是否在 save_path 下创建与远程文件夹同名的文件夹 1316 | :param callback 用于显示单个文件下载进度的回调函数 1317 | :param recursive 是否递归下载子文件夹(vip用户) 1318 | :param failed_callback 用于处理下载失败文件的回调函数, 1319 | def failed_callback(code, file): 1320 | print(f"文件名: {file.name}, 时间: {file.time}, 大小: {file.size}, 类型: {file.type}") # 共有属性 1321 | if hasattr(file, 'url'): # 使用 URL 下载时 1322 | print(f"文件下载失败, 链接: {file.url}, 错误码: {code}") 1323 | else: # 登录后使用 ID 下载时 1324 | print(f"文件下载失败, ID: {file.id}, 错误码: {code}") 1325 | :param downloaded_handler: 单个文件下载完成后进一步处理的回调函数 downloaded_handle(file_path) 1326 | """ 1327 | folder_detail = self.get_folder_info_by_url(share_url, dir_pwd) 1328 | if folder_detail.code != LanZouCloud.SUCCESS: # 获取文件信息失败 1329 | return folder_detail.code 1330 | 1331 | # 检查是否大文件分段数据 1332 | info = self._check_big_file(folder_detail.files) 1333 | if info is not None: 1334 | return self._down_big_file(*info, save_path, callback=callback, overwrite=overwrite, 1335 | downloaded_handler=downloaded_handler) 1336 | 1337 | if mkdir: # 自动创建子文件夹 1338 | save_path = save_path + os.sep + folder_detail.folder.name 1339 | if not os.path.exists(save_path): 1340 | save_path = save_path.replace('*', '_') # 替换特殊字符以符合路径规则 1341 | os.makedirs(save_path) 1342 | 1343 | # 不是大文件分段数据,直接下载 1344 | for file in folder_detail.files: 1345 | code = self.down_file_by_url(file.url, dir_pwd, save_path, callback=callback, overwrite=overwrite, 1346 | downloaded_handler=downloaded_handler) 1347 | logger.debug(f'Download file result: Code:{code}, File: {file}') 1348 | if code != LanZouCloud.SUCCESS: 1349 | if failed_callback is not None: 1350 | failed_callback(code, file) 1351 | 1352 | # 如果有子文件夹则递归下载子文件夹 1353 | if recursive and folder_detail.sub_folders: 1354 | for sub_folder in folder_detail.sub_folders: 1355 | self.down_dir_by_url(sub_folder.url, dir_pwd, save_path, callback=callback, 1356 | overwrite=overwrite, 1357 | recursive=True, failed_callback=failed_callback, 1358 | downloaded_handler=downloaded_handler) 1359 | 1360 | return LanZouCloud.SUCCESS 1361 | 1362 | def down_dir_by_id(self, folder_id, save_path='./Download', *, callback=None, mkdir=True, overwrite=False, 1363 | failed_callback=None, downloaded_handler=None, recursive=False) -> int: 1364 | """登录用户通过id下载文件夹""" 1365 | file_list = self.get_file_list(folder_id) 1366 | if len(file_list) == 0: 1367 | return LanZouCloud.FAILED 1368 | 1369 | # 检查是否大文件分段数据 1370 | info = self._check_big_file(file_list) 1371 | if info is not None: 1372 | return self._down_big_file(*info, save_path, callback=callback, overwrite=overwrite, 1373 | downloaded_handler=downloaded_handler) 1374 | 1375 | if mkdir: # 自动创建子目录 1376 | share_info = self.get_share_info(folder_id, False) 1377 | if share_info.code != LanZouCloud.SUCCESS: 1378 | return share_info.code 1379 | save_path = save_path + os.sep + share_info.name 1380 | if not os.path.exists(save_path): 1381 | logger.debug(f"Mkdir {save_path}") 1382 | os.makedirs(save_path) 1383 | 1384 | for file in file_list: 1385 | code = self.down_file_by_id(file.id, save_path, callback=callback, overwrite=overwrite, 1386 | downloaded_handler=downloaded_handler) 1387 | logger.debug(f'Download file result: Code:{code}, File: {file}') 1388 | if code != LanZouCloud.SUCCESS: 1389 | if failed_callback is not None: 1390 | failed_callback(code, file) 1391 | 1392 | if recursive: 1393 | sub_folders = self.get_dir_list(folder_id) 1394 | if len(sub_folders) != 0: 1395 | for sub_folder in sub_folders: 1396 | self.down_dir_by_id(sub_folder.id, save_path, callback=callback, overwrite=overwrite, 1397 | failed_callback=failed_callback, downloaded_handler=downloaded_handler, 1398 | recursive=True) 1399 | 1400 | return LanZouCloud.SUCCESS 1401 | --------------------------------------------------------------------------------