From 847cab47f00f83df9a27f911d9392925dfced20e Mon Sep 17 00:00:00 2001 From: limil Date: Sat, 19 Oct 2024 22:43:09 +0800 Subject: [PATCH] init --- .gitignore | 108 +++++++++++++++++++ main.py | 189 +++++++++++++++++++++++++++++++++ pikpakFs.py | 266 +++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 3 + requirements.txt | 25 +++++ 5 files changed, 591 insertions(+) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 pikpakFs.py create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..901e8aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# add +.idea/ +token.json diff --git a/main.py b/main.py new file mode 100644 index 0000000..5406ea6 --- /dev/null +++ b/main.py @@ -0,0 +1,189 @@ +from textual import events +from textual.app import App, ComposeResult +from textual.widgets import Input, Log +from textual.containers import Horizontal, Vertical, Widget +from collections import deque +import sys +import asyncio +import argparse +import pikpakFs +import logging +import functools + +class TextualLogHandler(logging.Handler): + def __init__(self, log_widget: Log): + super().__init__() + self.log_widget = log_widget + + def emit(self, record): + message = self.format(record) + self.log_widget.write_line(message) + +class HistoryInput(Input): + def __init__(self, placeholder: str = "", max_history: int = 20, *args, **kwargs): + super().__init__(placeholder=placeholder, *args, **kwargs) + self.block_input = False + self.history = deque(maxlen=max_history) # 历史记录列表 + self.history_view = list() + self.history_index = -1 # 当前历史索引,初始为 -1 + self.history_log = Log(auto_scroll=False) # 用于显示历史记录的日志小部件 + + def widget(self) -> Widget: + return Vertical(self, self.history_log) + + def reverseIdx(self, idx) -> int: + return len(self.history) - 1 - idx + + async def on_key(self, event: events.Key) -> None: + if self.block_input: + return + if event.key == "up": + if self.history_index == -1: + self.cursor_position = len(self.value) + await self.update_history_view() + return + self.history_index = max(0, self.history_index - 1) + elif event.key == "down": + self.history_index = min(len(self.history) - 1, self.history_index + 1) + else: + self.history_index = -1 + await self.update_history_view() + return + + if len(self.history) > 0 and self.history_index != -1: + self.value = self.history[self.reverseIdx(self.history_index)] + self.cursor_position = len(self.value) + await self.update_history_view() + + async def on_input_submitted(self, event: Input.Submitted) -> None: + user_input = event.value.strip() + if user_input: + self.history.append(user_input) + self.history_index = -1 + self.value = "" + await self.update_history_view() + + async def update_history_view(self): + self.history_log.clear() + self.history_view.clear() + + if self.history: + for idx, item in enumerate(self.history): + prefix = "> " if self.reverseIdx(idx) == self.history_index else " " + self.history_view.append(f"{prefix}{item}") + + self.history_log.write_lines(reversed(self.history_view)) + + scroll_height = self.history_log.scrollable_size.height + scroll_start = self.history_log.scroll_offset.y + current = self.history_index + + if current < scroll_start: + scroll_idx = min(max(0, current), len(self.history) - 1) + self.history_log.scroll_to(y = scroll_idx) + elif current >= scroll_start + scroll_height - 1: + self.history_log.scroll_to(y = current - scroll_height + 1) + + self.refresh() + + async def animate_ellipsis(self): + ellipsis = "" + try: + while True: + # 循环添加省略号(最多3个点) + if len(ellipsis) < 3: + ellipsis += "." + else: + ellipsis = "" + self.value = f"Waiting{ellipsis}" + await asyncio.sleep(0.5) + finally: + self.value = "" + pass + + async def wait_for(self, operation): + self.disabled = True + self.block_input = True + animation_task = asyncio.create_task(self.animate_ellipsis()) + await operation() + animation_task.cancel() + self.disabled = False + self.block_input = False + self.focus() + + +class InputLoggerApp(App): + CSS = """ + .divider { + width: 0.5%; + height: 100%; + background: #444444; + } + .log { + width: 80%; + height: 100%; + } + """ + + def setup_logger(self) -> None: + formatStr = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + logging.basicConfig( + filename='app.log', + filemode='a', + format=formatStr + ) + + logHandler = TextualLogHandler(self.log_widget) + + # 设置日志格式 + logHandler.setFormatter(logging.Formatter(formatStr)) + + # 获取根日志记录器,并添加自定义处理器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.addHandler(logHandler) + + def write_to_console(self, content) -> None: + self.log_widget.write_line(content) + + def compose(self) -> ComposeResult: + self.input_widget = HistoryInput(placeholder="Input Command...") + self.log_widget = Log(classes="log", highlight=True) + + left_panel = self.input_widget.widget() + right_panel = self.log_widget + divider = Vertical(classes="divider") + + yield Horizontal(left_panel, divider, right_panel) + + def on_mount(self) -> None: + self.setup_logger() + self.fs = pikpakFs.VirtFs("", "", "", loginCachePath = "token.json") + + async def handle_command(self, command) -> None: + try: + if command == "clear": + self.log_widget.clear() + elif command == "exit": + sys.exit(0) + elif command == "debug": + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + self.write_to_console("Done") + else: + self.write_to_console(await self.fs.HandlerCommand(command)) + except Exception as e: + logging.exception(e) + + async def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input is not self.input_widget: + return + + user_input = event.value.strip() + self.write_to_console(f"> {user_input}") + await self.input_widget.wait_for(functools.partial(self.handle_command, user_input)) + +if __name__ == "__main__": + app = InputLoggerApp() + app.run() diff --git a/pikpakFs.py b/pikpakFs.py new file mode 100644 index 0000000..32a7834 --- /dev/null +++ b/pikpakFs.py @@ -0,0 +1,266 @@ +import httpx +from hashlib import md5 +from pikpakapi import PikPakApi +from typing import Dict +from datetime import datetime +import json +import re +import os +import logging + +class PathWalker(): + def __init__(self, pathStr : str, subDir : str = None, sep : str = "/"): + self.pathSpots : list[str] = [] + pathStr = pathStr.strip() + if not pathStr.startswith(sep): + self.pathSpots.append(".") + pathSpots = [spot.strip() for spot in pathStr.split(sep) if spot.strip() != ""] + self.pathSpots.extend(pathSpots) + if subDir != None: + self.pathSpots.append(subDir) + + def IsAbsolute(self) -> bool: + return len(self.pathSpots) == 0 or self.pathSpots[0] != "." + +class VirtFsNode: + def __init__(self, id : str, name : str, fatherId : str): + self.id = id + self.name = name + self.fatherId = fatherId + +class DirNode(VirtFsNode): + def __init__(self, id : str, name : str, fatherId : str, childrenId : list[str]): + super().__init__(id, name, fatherId) + self.childrenId = childrenId + self.lastUpdate : datetime = None + + +class FileNode(VirtFsNode): + def __init__(self, id : str, name : str, fatherId : str): + super().__init__(id, name, fatherId) + self.lastUpdate : datetime = None + +class PikpakToken: + def __init__(self, username, password, access_token, refresh_token, user_id): + self.username = username + self.password = password + self.access_token = access_token + self.refresh_token = refresh_token + self.user_id = user_id + + def to_json(self): + return json.dumps(self.__dict__) + + @classmethod + def from_json(cls, json_str): + data = json.loads(json_str) + return cls(**data) + +class VirtFs: + def __CalcMd5(self, text : str): + return md5(text.encode()).hexdigest() + + def __init__(self, username : str, password : str, proxy : str = None, loginCachePath : str = None): + httpx_client_args = None + if proxy != None: + httpx_client_args = { + "proxy": proxy, + "transport": httpx.AsyncHTTPTransport(retries=1), + } + + self.client = PikPakApi( + username = username, + password = password, + httpx_client_args=httpx_client_args) + + self.nodes : Dict[str, VirtFsNode] = {} + self.loginCachePath = loginCachePath + self.root = DirNode(None, "", None, []) + self.currentLocation = self.root + self.__LoginFromCache() + + def __LoginFromCache(self): + if self.loginCachePath == None: + return + if not os.path.exists(self.loginCachePath): + return + with open(self.loginCachePath, 'r', encoding='utf-8') as file: + content = file.read() + token = PikpakToken.from_json(content) + if self.client.username != token.username or self.client.password != token.password: + logging.error("failed to load login info from cache, not match") + return + self.client.access_token = token.access_token + self.client.refresh_token = token.refresh_token + self.client.user_id = token.user_id + self.client.encode_token() + logging.info("successfully load login info from cache") + + def __DumpLoginInfo(self): + if self.loginCachePath == None: + return + with open(self.loginCachePath, 'w', encoding='utf-8') as file: + token = PikpakToken(self.client.username, self.client.password, self.client.access_token, self.client.refresh_token, self.client.user_id) + file.write(token.to_json()) + logging.info("successfully dump login info to cache") + + async def __RefreshAccessToken(self): + result = await self.client.refresh_access_token() + return json.dumps(result, indent=4) + + async def __RefreshDirectory(self, dirNode : DirNode): + dirInfo = await self.client.file_list(parent_id = dirNode.id) + nodes = dirInfo["files"] + dirNode.childrenId.clear() + + for node in nodes: + child : VirtFsNode = None + id = node["id"] + name = node["name"] + + if id in self.nodes: + child = self.nodes[id] + else: + if node["kind"].endswith("folder"): + child = DirNode(id, name, dirNode.id, []) + else: + child = FileNode(id, name, dirNode.id) + self.nodes[id] = child + + child.name = name + dirNode.childrenId.append(id) + + dirNode.lastUpdate = datetime.now() + + async def __PathToNode(self, pathStr : str, subDir : str = None) -> VirtFsNode: + pathWalker = PathWalker(pathStr, subDir) + current : VirtFsNode = None + if pathWalker.IsAbsolute(): + current = self.root + else: + current = self.currentLocation + + for spot in pathWalker.pathSpots: + if current == None: + break + if spot == "..": + if current.fatherId == None: + current = self.root + else: + current = self.nodes[current.fatherId] + continue + + if not isinstance(current, DirNode): + return None + + currentDir : DirNode = current + if currentDir.lastUpdate == None: + await self.__RefreshDirectory(currentDir) + + if spot == ".": + continue + else: + current = None + for childId in currentDir.childrenId: + node = self.nodes[childId] + if spot == node.name: + current = node + break + + return current + + async def __NodeToPath(self, node : VirtFsNode) -> str: + spots : list[str] = [""] + current = node + while current.id != None: + spots.append(current.name) + if current.fatherId == None: + break + current = self.nodes[current.fatherId] + spots.append("") + return "/".join(reversed(spots)) + + async def login(self): + result = await self.client.login() + self.__DumpLoginInfo() + logging.debug(json.dumps(result, indent=4)) + return "Login Success" + + async def ls(self, pathStr : str = "") -> str: + node = await self.__PathToNode(pathStr) + if node == None: + return f"path not found: {pathStr}" + if not isinstance(node, DirNode): + return f"path is not directory" + dirNode : DirNode = node + result = ["==== ls ===="] + for childId in dirNode.childrenId: + node = self.nodes[childId] + result.append(node.name) + return "\n".join(result) + + async def cd(self, pathStr : str = "") -> str: + node = await self.__PathToNode(pathStr) + if node == None: + return f"path not found: {pathStr}" + if not isinstance(node, DirNode): + return f"path is not directory" + dirNode : DirNode = node + self.currentLocation = dirNode + return "" + + async def cwd(self) -> str: + path = await self.__NodeToPath(self.currentLocation) + if path == None: + return f"cwd failed" + return path + + async def geturl(self, pathStr : str) -> str: + node = await self.__PathToNode(pathStr) + if node == None: + return f"path not found: {pathStr}" + if not isinstance(node, FileNode): + return f"path is not file" + result = await self.client.get_download_url(node.id) + logging.debug(json.dumps(result, indent=4)) + return result["web_content_link"] + + async def offdown(self, url : str, pathStr : str = "") -> str : + node = await self.__PathToNode(pathStr) + if node == None: + return f"path not found: {pathStr}" + elif not isinstance(node, DirNode): + return f"path is not directory" + + subFolderName = self.__CalcMd5(url) + subNode = await self.__PathToNode(pathStr, subFolderName) + if subNode == None: + result = await self.client.create_folder(subFolderName, node.id) + logging.debug(json.dumps(result, indent=4)) + await self.__RefreshDirectory(node) + subNode = await self.__PathToNode(pathStr, subFolderName) + elif not isinstance(subNode, DirNode): + return f"path is not directory" + + if subNode == None: + return f"path not found: {pathStr}" + elif not isinstance(subNode, DirNode): + return f"path is not directory" + + result = await self.client.offline_download(url, subNode.id) + logging.debug(json.dumps(result, indent=4)) + + return subFolderName + + + async def HandlerCommand(self, command): + result = re.findall(r'"(.*?)"|(\S+)', command) + filtered_result = [item for sublist in result for item in sublist if item] + + command = filtered_result[0] + args = filtered_result[1:] + + method = getattr(self, command) + if method == None: + return f"Unknown command: {command}" + return await method(*args) \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..1bd52e5 --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +在Pikpak Api基础上套了一层文件系统,更好自动化离线下载 + +python main.py 运行 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ccc046d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +anyio==4.6.2.post1 +certifi==2024.8.30 +charset-normalizer==3.4.0 +DataRecorder==3.6.2 +DownloadKit==2.0.5 +et-xmlfile==1.1.0 +h11==0.14.0 +httpcore==1.0.6 +httpx==0.27.2 +idna==3.10 +linkify-it-py==2.0.3 +markdown-it-py==3.0.0 +mdit-py-plugins==0.4.2 +mdurl==0.1.2 +openpyxl==3.1.5 +PikPakAPI==0.1.10 +platformdirs==4.3.6 +Pygments==2.18.0 +requests==2.32.3 +rich==13.9.2 +sniffio==1.3.1 +textual==0.83.0 +typing_extensions==4.12.2 +uc-micro-py==1.0.3 +urllib3==2.2.3