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;
+}