From f6c232d14c05b8044004555b70631c871075c363 Mon Sep 17 00:00:00 2001 From: yongquan Date: Sat, 20 Jan 2024 16:48:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++ requirements.txt | 7 +-- source/TUI/app.py | 23 +++++++-- source/TUI/index.py | 90 ++++++---------------------------- source/TUI/loading.py | 21 ++++++-- source/TUI/progress.py | 9 ++++ source/TUI/setting.py | 2 - source/TUI/update.py | 70 ++++++++++++++++++++++++++ source/application/download.py | 31 +++++++++--- source/application/request.py | 2 + source/module/manager.py | 2 +- source/translator/chinese.py | 16 +++--- source/translator/english.py | 2 + static/XHS-Downloader.js | 24 ++++----- static/XHS-Downloader.tcss | 13 ++++- 15 files changed, 201 insertions(+), 116 deletions(-) create mode 100644 source/TUI/progress.py create mode 100644 source/TUI/update.py diff --git a/README.md b/README.md index 207858c..aeba300 100644 --- a/README.md +++ b/README.md @@ -245,3 +245,8 @@ async with XHS(work_path=work_path,
  • 基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因二次开发可能带来的各种情况负全部责任。
  • 在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声明有任何疑问或不同意,请不要使用本项目的代码和功能。如果您使用了本项目的代码和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险和后果。 + +# 💡 代码参考 + +* https://textual.textualize.io/ +* https://docs.aiohttp.org/en/stable/ diff --git a/requirements.txt b/requirements.txt index 4f6eebc..ab6a106 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -aiohttp>=3.9.0 -textual>=0.40.0 +aiohttp>=3.9.1 +textual>=0.47.1 pyperclip>=1.8.2 -lxml>=4.9.3 +lxml>=5.1.0 PyYAML>=6.0.1 +aiosqlite>=0.19.0 diff --git a/source/TUI/app.py b/source/TUI/app.py index cf52076..d6aef44 100644 --- a/source/TUI/app.py +++ b/source/TUI/app.py @@ -1,6 +1,7 @@ from typing import Type from textual.app import App +from textual.widgets import RichLog from source.application import XHS from source.module import ( @@ -13,13 +14,16 @@ from source.translator import ( English, ) from .index import Index +from .loading import Loading from .setting import Setting +from .update import Update __all__ = ["XHSDownloader"] class XHSDownloader(App): - settings = Settings(ROOT) + CSS_PATH = ROOT.joinpath("static/XHS-Downloader.tcss") + SETTINGS = Settings(ROOT) def __init__(self): super().__init__() @@ -36,7 +40,7 @@ class XHSDownloader(App): await self.APP.__aexit__(exc_type, exc_value, traceback) def __initialization(self) -> None: - self.parameter = self.settings.run() + self.parameter = self.SETTINGS.run() self.prompt = LANGUAGE.get(self.parameter["language"], Chinese) self.APP = XHS(**self.parameter, language_object=self.prompt) @@ -47,11 +51,12 @@ class XHSDownloader(App): self.prompt), name="setting") self.install_screen(Index(self.APP, self.prompt), name="index") + self.install_screen(Loading(self.prompt), name="loading") await self.push_screen("index") async def action_settings(self): async def save_settings(data: dict) -> None: - self.settings.update(data) + self.SETTINGS.update(data) await self.refresh_screen() await self.push_screen("setting", save_settings) @@ -62,12 +67,20 @@ class XHSDownloader(App): async def refresh_screen(self): self.pop_screen() self.__initialization() + self.uninstall_screen("index") self.uninstall_screen("setting") + self.uninstall_screen("loading") + self.install_screen(Index(self.APP, self.prompt), name="index") self.install_screen( Setting( self.parameter, self.prompt), name="setting") - self.uninstall_screen("index") - self.install_screen(Index(self.APP, self.prompt), name="index") + self.install_screen(Loading(self.prompt), name="loading") await self.push_screen("index") + + def update_result(self, tip: str) -> None: + self.query_one(RichLog).write(tip) + + async def action_check_update(self): + await self.push_screen(Update(self.APP, self.prompt), callback=self.update_result) diff --git a/source/TUI/index.py b/source/TUI/index.py index aef2267..bcecd10 100644 --- a/source/TUI/index.py +++ b/source/TUI/index.py @@ -6,7 +6,6 @@ from rich.text import Text from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Center from textual.containers import HorizontalScroll from textual.containers import ScrollableContainer from textual.screen import Screen @@ -15,11 +14,9 @@ from textual.widgets import Footer from textual.widgets import Header from textual.widgets import Input from textual.widgets import Label -from textual.widgets import ProgressBar from textual.widgets import RichLog from source.application import XHS -from source.module import ROOT from source.module import ( VERSION_MAJOR, VERSION_MINOR, @@ -28,32 +25,20 @@ from source.module import ( MASTER, ERROR, WARNING, - INFO, LICENCE, REPOSITORY, - RELEASES, GENERAL, USERSCRIPT, ) -from source.translator import (English, Chinese) +from source.translator import ( + English, + Chinese, +) __all__ = ["Index"] -def show_state(function): - async def inner(self, *args, **kwargs): - self.close_disclaimer() - self.bar.update(total=100, progress=100) - result = await function(self, *args, **kwargs) - self.bar.update(total=None) - self.tip.write(Text(">" * 50, style=GENERAL)) - return result - - return inner - - class Index(Screen): - CSS_PATH = ROOT.joinpath("static/XHS-Downloader.tcss") BINDINGS = [ Binding(key="q", action="quit", description="退出程序/Quit"), Binding(key="u", action="check_update", description="检查更新/Update"), @@ -63,12 +48,10 @@ class Index(Screen): def __init__(self, app: XHS, language: Chinese | English): super().__init__() - self.app_ = app + self.xhs = app self.prompt = language self.url = None self.tip = None - self.bar = None - self.disclaimer = True def compose(self) -> ComposeResult: yield Header() @@ -95,9 +78,7 @@ class Index(Screen): Button(self.prompt.reset_button, id="reset"), ), ) - with Center(): - yield ProgressBar(total=None, show_percentage=False, show_eta=False) - yield RichLog(markup=True) + yield RichLog(markup=True, wrap=True) yield Footer() def on_mount(self) -> None: @@ -105,17 +86,15 @@ class Index(Screen): VERSION_MINOR}{" Beta" if VERSION_BETA else ""}" self.url = self.query_one(Input) self.tip = self.query_one(RichLog) - self.bar = self.query_one(ProgressBar) self.tip.write(Text("\n".join(self.prompt.disclaimer), style=MASTER)) - def close_disclaimer(self): - if self.disclaimer: - self.tip.clear() - self.disclaimer = False - @on(Button.Pressed, "#deal") - def deal_button(self): - create_task(self.deal()) + async def deal_button(self): + if self.url.value: + await create_task(self.deal()) + else: + self.tip.write(Text(self.prompt.invalid_link, style=WARNING)) + self.tip.write(Text(">" * 50, style=GENERAL)) @on(Button.Pressed, "#reset") def reset_button(self): @@ -125,52 +104,13 @@ class Index(Screen): def paste_button(self): self.query_one(Input).value = paste() - @show_state async def deal(self): - if not self.url.value: - self.tip.write(Text(self.prompt.invalid_link, style=WARNING)) - return - if any(await self.app_.extract(self.url.value, True, log=self.tip)): + await self.app.push_screen("loading") + if any(await self.xhs.extract(self.url.value, True, log=self.tip)): self.url.value = "" else: self.tip.write(Text(self.prompt.download_failure, style=ERROR)) - - @show_state - async def action_check_update(self): - self.tip.write( - Text( - self.prompt.check_update_notification, - style=WARNING)) - try: - url = await self.app_.html.request_url(RELEASES, False, self.tip) - latest_major, latest_minor = map( - int, url.split("/")[-1].split(".", 1)) - if latest_major > VERSION_MAJOR or latest_minor > VERSION_MINOR: - self.tip.write( - Text( - self.prompt.official_version_update( - latest_major, - latest_minor), - style=WARNING)) - self.tip.write(RELEASES) - elif latest_minor == VERSION_MINOR and VERSION_BETA: - self.tip.write( - Text( - self.prompt.development_version_update, - style=WARNING)) - self.tip.write(RELEASES) - elif VERSION_BETA: - self.tip.write( - Text( - self.prompt.latest_development_version, - style=WARNING)) - else: - self.tip.write( - Text( - self.prompt.latest_official_version, - style=INFO)) - except ValueError: - self.tip.write(Text(self.prompt.check_update_failure, style=ERROR)) + self.app.pop_screen() @staticmethod def action_user_script(): diff --git a/source/TUI/loading.py b/source/TUI/loading.py index 69b6e25..1abb75b 100644 --- a/source/TUI/loading.py +++ b/source/TUI/loading.py @@ -1,10 +1,25 @@ from textual.app import ComposeResult -from textual.screen import Screen +from textual.containers import Grid +from textual.screen import ModalScreen +from textual.widgets import Label from textual.widgets import LoadingIndicator +from source.translator import ( + English, + Chinese, +) + __all__ = ["Loading"] -class Loading(Screen): +class Loading(ModalScreen): + def __init__(self, language: Chinese | English): + super().__init__() + self.prompt = language + def compose(self) -> ComposeResult: - yield LoadingIndicator() + yield Grid( + Label(self.prompt.processing), + LoadingIndicator(), + classes="loading", + ) diff --git a/source/TUI/progress.py b/source/TUI/progress.py new file mode 100644 index 0000000..04008d0 --- /dev/null +++ b/source/TUI/progress.py @@ -0,0 +1,9 @@ +from textual.app import ComposeResult +from textual.screen import Screen + +__all__ = ["Progress"] + + +class Progress(Screen): + def compose(self) -> ComposeResult: + pass diff --git a/source/TUI/setting.py b/source/TUI/setting.py index a521140..614cabd 100644 --- a/source/TUI/setting.py +++ b/source/TUI/setting.py @@ -12,7 +12,6 @@ from textual.widgets import Input from textual.widgets import Label from textual.widgets import Select -from source.module import ROOT from source.translator import ( LANGUAGE, Chinese, @@ -23,7 +22,6 @@ __all__ = ["Setting"] class Setting(Screen): - CSS_PATH = ROOT.joinpath("static/XHS-Downloader.tcss") BINDINGS = [ Binding(key="q", action="quit", description="退出程序/Quit"), Binding(key="b", action="index", description="返回首页/Back"), diff --git a/source/TUI/update.py b/source/TUI/update.py new file mode 100644 index 0000000..e89e44d --- /dev/null +++ b/source/TUI/update.py @@ -0,0 +1,70 @@ +from aiohttp import ClientTimeout +from rich.text import Text +from textual import work +from textual.app import ComposeResult +from textual.containers import Grid +from textual.screen import ModalScreen +from textual.widgets import Label +from textual.widgets import LoadingIndicator + +from source.application import XHS +from source.module import ( + VERSION_MAJOR, + VERSION_MINOR, + VERSION_BETA, + ERROR, + WARNING, + INFO, + RELEASES, +) +from source.translator import ( + English, + Chinese, +) + +__all__ = ["Update"] + + +class Update(ModalScreen): + def __init__(self, app: XHS, language: Chinese | English): + super().__init__() + self.xhs = app + self.prompt = language + + def compose(self) -> ComposeResult: + yield Grid( + Label(self.prompt.check_update_notification), + LoadingIndicator(), + classes="loading", + ) + + @work() + async def check_update(self) -> None: + try: + url = await self.xhs.html.request_url(RELEASES, False, None, timeout=ClientTimeout(connect=5)) + latest_major, latest_minor = map( + int, url.split("/")[-1].split(".", 1)) + if latest_major > VERSION_MAJOR or latest_minor > VERSION_MINOR: + tip = Text( + f"{self.prompt.official_version_update( + latest_major, + latest_minor)}\n{RELEASES}", + style=WARNING) + elif latest_minor == VERSION_MINOR and VERSION_BETA: + tip = Text( + f"{self.prompt.development_version_update}\n{RELEASES}", + style=WARNING) + elif VERSION_BETA: + tip = Text( + self.prompt.latest_development_version, + style=WARNING) + else: + tip = Text( + self.prompt.latest_official_version, + style=INFO) + except ValueError: + tip = Text(self.prompt.check_update_failure, style=ERROR) + self.dismiss(tip) + + def on_mount(self) -> None: + self.check_update() diff --git a/source/application/download.py b/source/application/download.py index f213263..cf8fde7 100644 --- a/source/application/download.py +++ b/source/application/download.py @@ -1,3 +1,4 @@ +from asyncio import gather from pathlib import Path from aiohttp import ClientError @@ -36,12 +37,13 @@ class Download: path = self.__generate_path(name) match type_: case "视频": - await self.__download(urls[0], path, f"{name}", self.video_format, log, bar) + tasks = self.__ready_download_video(urls, path, name, log) case "图文": - for index, url in enumerate(urls, start=1): - await self.__download(url, path, f"{name}_{index}", self.image_format, log, bar) + tasks = self.__ready_download_image(urls, path, name, log) case _: raise ValueError + tasks = [self.__download(url, path, name, format_, log, bar) for url, name, format_ in tasks] + await gather(*tasks) return path def __generate_path(self, name: str): @@ -49,6 +51,22 @@ class Download: path.mkdir(exist_ok=True) return path + def __ready_download_video(self, urls: list[str], path: Path, name: str, log) -> list: + if any(path.glob(f"{name}.*")): + logging(log, self.prompt.skip_download(name)) + return [] + return [(urls[0], name, self.video_format)] + + def __ready_download_image(self, urls: list[str], path: Path, name: str, log) -> list: + tasks = [] + for i, j in enumerate(urls, start=1): + file = f"{name}_{i}" + if any(path.glob(f"{file}.*")): + logging(log, self.prompt.skip_download(file)) + continue + tasks.append([j, file, self.image_format]) + return tasks + @re_download async def __download(self, url: str, path: Path, name: str, format_: str, log, bar): try: @@ -58,10 +76,7 @@ class Download: suffix = self.__extract_type( response.headers.get("Content-Type")) or format_ temp = self.temp.joinpath(name) - file = path.joinpath(name).with_suffix(f".{suffix}") - if self.manager.is_exists(file): - logging(log, self.prompt.skip_download(name)) - return True + real = path.joinpath(name).with_suffix(f".{suffix}") # self.__create_progress( # bar, int( # response.headers.get( @@ -70,7 +85,7 @@ class Download: async for chunk in response.content.iter_chunked(self.chunk): f.write(chunk) # self.__update_progress(bar, len(chunk)) - self.manager.move(temp, file) + self.manager.move(temp, real) # self.__create_progress(bar, None) logging(log, self.prompt.download_success(name)) return True diff --git a/source/application/request.py b/source/application/request.py index 1ea7bbf..642aa60 100644 --- a/source/application/request.py +++ b/source/application/request.py @@ -21,11 +21,13 @@ class Html: url: str, content=True, log=None, + **kwargs, ) -> str: try: async with self.session.get( url, proxy=self.proxy, + **kwargs, ) as response: if response.status != 200: return "" diff --git a/source/module/manager.py b/source/module/manager.py index 5565379..4ebbf51 100644 --- a/source/module/manager.py +++ b/source/module/manager.py @@ -19,7 +19,7 @@ __all__ = ["Manager"] class Manager: - NAME = compile(r"[^\u4e00-\u9fa5a-zA-Z0-9_]") + NAME = compile(r"[^\u4e00-\u9fa5a-zA-Z0-9]") def __init__( self, diff --git a/source/translator/chinese.py b/source/translator/chinese.py index 970d6d0..f43913d 100644 --- a/source/translator/chinese.py +++ b/source/translator/chinese.py @@ -6,13 +6,13 @@ class Chinese: disclaimer: tuple[str] = ( "关于 XHS-Downloader 的 免责声明:", "", - "1. 使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。", - "2. 本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者尽力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。", - "3. 使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。", - "4. 使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。", - "5. 使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。", - "6. 本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-Downloader 项目相关的任何商业服务。", - "7. 基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因" + "1.使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。", + "2.本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者尽力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。", + "3.使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。", + "4.使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。", + "5.使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。", + "6.本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-Downloader 项目相关的任何商业服务。", + "7.基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因" "二次开发可能带来的各种情况负全部责任。", "", "在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声明有任何疑问或不同意,请不要使用本项目的代码和功能。如果" @@ -67,6 +67,8 @@ class Chinese: save_button: str = "保存配置" abandon_button: str = "放弃更改" + processing: str = "程序处理中..." + @staticmethod def request_error(url: str) -> str: return f"网络异常,请求 {url} 失败!" diff --git a/source/translator/english.py b/source/translator/english.py index f30bb2e..e1199c9 100644 --- a/source/translator/english.py +++ b/source/translator/english.py @@ -85,6 +85,8 @@ class English(Chinese): save_button: str = "Save configuration" abandon_button: str = "Discard changes" + processing: str = "Processing..." + @staticmethod def request_error(url: str) -> str: return f"Network error, failed to access {url}!" diff --git a/static/XHS-Downloader.js b/static/XHS-Downloader.js index f97565d..0710651 100644 --- a/static/XHS-Downloader.js +++ b/static/XHS-Downloader.js @@ -1,7 +1,7 @@ // ==UserScript== // @name XHS-Downloader // @namespace https://github.com/JoeanAmier/XHS-Downloader -// @version 1.4 +// @version 1.4.1 // @description 提取小红书作品/用户链接,下载小红书无水印图文/视频作品文件 // @author JoeanAmier // @match http*://www.xiaohongshu.com/explore* @@ -385,16 +385,18 @@ const buttons = [createButton("Download", "下载无水印作品文件", extractDownloadLinks), createButton("Post", "提取发布作品链接", extractAllLinksEvent, 0), createButton("Collection", "提取收藏作品链接", extractAllLinksEvent, 1), createButton("Favorite", "提取点赞作品链接", extractAllLinksEvent, 2), createButton("Feed", "提取发现作品链接", extractAllLinksEvent, -1), createButton("Search", "提取搜索作品链接", extractAllLinksEvent, 3), createButton("User", "提取搜索用户链接", extractAllLinksEvent, 4), createButton("About", "关于 XHS-Downloader", about,)] const run = url => { - if (url === "https://www.xiaohongshu.com/explore") { - updateContainer(buttons.slice(4, 5)); - } else if (url.includes("https://www.xiaohongshu.com/explore/")) { - updateContainer(buttons.slice(0, 1)); - } else if (url.includes("https://www.xiaohongshu.com/user/profile/")) { - updateContainer(buttons.slice(1, 4)); - } else if (url.includes("https://www.xiaohongshu.com/search_result")) { - updateContainer(buttons.slice(5, 7)); - } - }; + setTimeout(function () { + if (url === "https://www.xiaohongshu.com/explore") { + updateContainer(buttons.slice(4, 5)); + } else if (url.includes("https://www.xiaohongshu.com/explore/")) { + updateContainer(buttons.slice(0, 1)); + } else if (url.includes("https://www.xiaohongshu.com/user/profile/")) { + updateContainer(buttons.slice(1, 4)); + } else if (url.includes("https://www.xiaohongshu.com/search_result")) { + updateContainer(buttons.slice(5, 7)); + } + }, 500) + } let currentUrl = window.location.href; diff --git a/static/XHS-Downloader.tcss b/static/XHS-Downloader.tcss index f6fde32..3403f06 100644 --- a/static/XHS-Downloader.tcss +++ b/static/XHS-Downloader.tcss @@ -1,4 +1,4 @@ -Screen, RichLog { +ScrollableContainer, RichLog { background: #2f3542; } Button { @@ -44,3 +44,14 @@ Bar > .bar--indeterminate { Bar > .bar--complete { color: #ff7f50; } +.loading { + grid-size: 1 2; + grid-gutter: 1; + width: 40vw; + height: 5; + background: #353b48; + border: double #747d8c; +} +ModalScreen { + align: center middle; +}