diff --git a/README.md b/README.md
index aeba300..e047c7d 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
🔥 小红书链接提取/作品采集工具:提取账号发布、收藏、点赞作品链接;提取搜索结果作品、用户链接;采集小红书作品信息;提取小红书作品下载地址;下载小红书无水印作品文件!
-❤️ 作者仅在 GitHub 发布 XHS-Downloader,没有任何收费计划,谨防上当受骗!
+❤️ 作者仅在 GitHub 发布 XHS-Downloader,未与任何个人或网站合作发布,项目没有任何收费计划,谨防上当受骗!
+
https://www.xiaohongshu.com/explore/作品ID项目根目录下的 settings.json 文件,首次运行自动生成,可以自定义部分运行参数。
建议自行设置 cookie 参数,若不设置该参数,程序功能可能无法正常使用!
| cookie | str | -小红书网页版 Cookie,无需登录,建议修改 | -默认 Cookie | +小红书网页版 Cookie,无需登录 | +无 |
| proxy | diff --git a/source/TUI/app.py b/source/TUI/app.py index d6aef44..34107ce 100644 --- a/source/TUI/app.py +++ b/source/TUI/app.py @@ -15,6 +15,7 @@ from source.translator import ( ) from .index import Index from .loading import Loading +from .monitor import Monitor from .setting import Setting from .update import Update @@ -84,3 +85,6 @@ class XHSDownloader(App): async def action_check_update(self): await self.push_screen(Update(self.APP, self.prompt), callback=self.update_result) + + async def action_clipboard(self): + await self.push_screen(Monitor(self.APP, self.prompt)) diff --git a/source/TUI/index.py b/source/TUI/index.py index bcecd10..f9ae2b8 100644 --- a/source/TUI/index.py +++ b/source/TUI/index.py @@ -18,9 +18,7 @@ from textual.widgets import RichLog from source.application import XHS from source.module import ( - VERSION_MAJOR, - VERSION_MINOR, - VERSION_BETA, + PROJECT, PROMPT, MASTER, ERROR, @@ -44,6 +42,7 @@ class Index(Screen): Binding(key="u", action="check_update", description="检查更新/Update"), Binding(key="m", action="user_script", description="获取脚本/Script"), Binding(key="s", action="settings", description="程序设置/Settings"), + Binding(key="c", action="clipboard", description="监听链接/ClipBoard"), ] def __init__(self, app: XHS, language: Chinese | English): @@ -82,8 +81,7 @@ class Index(Screen): yield Footer() def on_mount(self) -> None: - self.title = f"XHS-Downloader V{VERSION_MAJOR}.{ - VERSION_MINOR}{" Beta" if VERSION_BETA else ""}" + self.title = PROJECT self.url = self.query_one(Input) self.tip = self.query_one(RichLog) self.tip.write(Text("\n".join(self.prompt.disclaimer), style=MASTER)) diff --git a/source/TUI/monitor.py b/source/TUI/monitor.py new file mode 100644 index 0000000..0b08325 --- /dev/null +++ b/source/TUI/monitor.py @@ -0,0 +1,62 @@ +from rich.text import Text +from textual import on +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import Button +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import Label +from textual.widgets import RichLog + +from source.application import XHS +from source.module import ( + PROJECT, + MASTER, + INFO, +) +from source.translator import ( + English, + Chinese, +) + +__all__ = ["Monitor"] + + +class Monitor(Screen): + BINDINGS = [ + Binding(key="q", action="quit", description="退出程序/Quit"), + Binding(key="c", action="close", description="关闭监听/Close"), + ] + + def __init__(self, app: XHS, language: Chinese | English): + super().__init__() + self.xhs = app + self.prompt = language + + def compose(self) -> ComposeResult: + yield Header() + yield Label(Text(self.prompt.monitor_mode, style=INFO), id="monitor") + yield RichLog(markup=True, wrap=True) + yield Button(self.prompt.close_monitor, id="close") + yield Footer() + + @on(Button.Pressed, "#close") + def close_button(self): + self.action_close() + + @work() + async def run_monitor(self): + await self.xhs.monitor(download=True, log=self.query_one(RichLog)) + self.action_close() + + def on_mount(self) -> None: + self.title = PROJECT + self.query_one(RichLog).write( + Text(self.prompt.monitor_text, style=MASTER)) + self.run_monitor() + + def action_close(self): + self.xhs.stop_monitor() + self.app.pop_screen() diff --git a/source/TUI/setting.py b/source/TUI/setting.py index 614cabd..d07c2f7 100644 --- a/source/TUI/setting.py +++ b/source/TUI/setting.py @@ -44,7 +44,7 @@ class Setting(Screen): Input(self.data["user_agent"], placeholder=self.prompt.user_agent_placeholder, valid_empty=True, id="user_agent", ), Label(self.prompt.cookie, classes="params", ), - Input(self.data["cookie"], placeholder=self.prompt.cookie_placeholder, valid_empty=True, id="cookie", ), + Input(placeholder=self.__check_cookie(), valid_empty=True, id="cookie", ), Label(self.prompt.proxy, classes="params", ), Input(self.data["proxy"], placeholder=self.prompt.proxy_placeholder, valid_empty=True, id="proxy", ), Label(self.prompt.timeout, classes="params", ), @@ -80,6 +80,11 @@ class Setting(Screen): ) yield Footer() + def __check_cookie(self) -> str: + if self.data["cookie"]: + return self.prompt.cookie_placeholder_true + return self.prompt.cookie_placeholder_false + def on_mount(self) -> None: self.title = self.prompt.settings_title @@ -89,7 +94,7 @@ class Setting(Screen): "work_path": self.query_one("#work_path").value, "folder_name": self.query_one("#folder_name").value, "user_agent": self.query_one("#user_agent").value, - "cookie": self.query_one("#cookie").value, + "cookie": self.query_one("#cookie").value or self.data["cookie"], "proxy": self.query_one("#proxy").value or None, "timeout": int(self.query_one("#timeout").value), "chunk": int(self.query_one("#chunk").value), diff --git a/source/application/app.py b/source/application/app.py index c8822e7..7910504 100644 --- a/source/application/app.py +++ b/source/application/app.py @@ -1,5 +1,13 @@ +from asyncio import Event +from asyncio import Queue +from asyncio import QueueEmpty +from asyncio import gather +from asyncio import sleep +from contextlib import suppress from re import compile +from pyperclip import paste + from source.expansion import Converter from source.expansion import Namespace from source.module import Manager @@ -73,6 +81,9 @@ class XHS: self.explore = Explore() self.convert = Converter() self.download = Download(self.manager) + self.clipboard_cache: str = "" + self.queue = Queue() + self.event = Event() def __extract_image(self, container: dict, data: Namespace): container["下载地址"] = self.image.get_image_link( @@ -141,10 +152,32 @@ class XHS: return Namespace(data) def __naming_rules(self, data: dict) -> str: - """下载文件默认使用 作品标题 或 作品 ID 作为文件名称,可修改此方法自定义文件名称格式""" + time_ = data["发布时间"].replace(":", ".") author = self.manager.filter_name(data["作者昵称"]) or data["作者ID"] title = self.manager.filter_name(data["作品标题"]) or data["作品ID"] - return f"{author}-{title}" + return f"{time_}_{author}_{title[:64]}" + + async def monitor(self, delay=1, download=False, efficient=False, log=None, bar=None) -> None: + self.event.clear() + await gather(self.__push_link(delay), self.__receive_link(delay, download, efficient, log, bar)) + + async def __push_link(self, delay: int): + while not self.event.is_set(): + if (t := paste()).lower() == "close": + self.stop_monitor() + elif t != self.clipboard_cache: + self.clipboard_cache = t + [await self.queue.put(i) for i in await self.__extract_links(t, None)] + await sleep(delay) + + async def __receive_link(self, delay: int, *args, **kwargs): + while not self.event.is_set() or self.queue.qsize() > 0: + with suppress(QueueEmpty): + await self.__deal_extract(self.queue.get_nowait(), *args, **kwargs) + await sleep(delay) + + def stop_monitor(self): + self.event.set() @staticmethod async def __suspend(efficient: bool) -> None: diff --git a/source/application/download.py b/source/application/download.py index cf8fde7..db39e47 100644 --- a/source/application/download.py +++ b/source/application/download.py @@ -42,7 +42,16 @@ class Download: 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] + tasks = [ + self.__download( + url, + path, + name, + format_, + log, + bar) for url, + name, + format_ in tasks] await gather(*tasks) return path @@ -51,13 +60,23 @@ class Download: path.mkdir(exist_ok=True) return path - def __ready_download_video(self, urls: list[str], path: Path, name: str, log) -> list: + 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: + 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}" @@ -76,7 +95,7 @@ class Download: suffix = self.__extract_type( response.headers.get("Content-Type")) or format_ temp = self.temp.joinpath(name) - real = path.joinpath(name).with_suffix(f".{suffix}") + real = path.joinpath(f"{name}.{suffix}") # self.__create_progress( # bar, int( # response.headers.get( diff --git a/source/application/explore.py b/source/application/explore.py index 8b97ade..a5aa6fe 100644 --- a/source/application/explore.py +++ b/source/application/explore.py @@ -6,7 +6,7 @@ __all__ = ['Explore'] class Explore: - time_format = "%Y-%m-%d %H:%M:%S" + time_format = "%Y-%m-%d_%H:%M:%S" explore_type = {"video": "视频", "normal": "图文"} def run(self, data: Namespace) -> dict: diff --git a/source/module/__init__.py b/source/module/__init__.py index a7512a2..245563f 100644 --- a/source/module/__init__.py +++ b/source/module/__init__.py @@ -19,8 +19,8 @@ from .static import ( INFO, USERSCRIPT, USERAGENT, - COOKIE, HEADERS, + PROJECT, ) from .tools import ( retry, @@ -49,9 +49,9 @@ __all__ = [ "INFO", "USERSCRIPT", "USERAGENT", - "COOKIE", "HEADERS", "retry", "logging", "wait", + "PROJECT", ] diff --git a/source/module/manager.py b/source/module/manager.py index 4ebbf51..6f16fff 100644 --- a/source/module/manager.py +++ b/source/module/manager.py @@ -11,7 +11,6 @@ from aiohttp import ClientTimeout from source.translator import Chinese from source.translator import English -from .static import COOKIE from .static import HEADERS from .static import USERAGENT @@ -19,7 +18,7 @@ __all__ = ["Manager"] class Manager: - NAME = compile(r"[^\u4e00-\u9fa5a-zA-Z0-9]") + NAME = compile(r"[^\u4e00-\u9fffa-zA-Z0-9!?,。;:“”()《》]") def __init__( self, @@ -43,7 +42,7 @@ class Manager: self.folder = self.__check_folder(folder) self.blank_headers = HEADERS | { "User-Agent": user_agent or USERAGENT, } - self.headers = self.blank_headers | {"Cookie": cookie or COOKIE} + self.headers = self.blank_headers | {"Cookie": cookie} self.retry = retry self.chunk = chunk self.record_data = record_data diff --git a/source/module/static.py b/source/module/static.py index e56e475..13a219f 100644 --- a/source/module/static.py +++ b/source/module/static.py @@ -17,14 +17,16 @@ __all__ = [ "INFO", "USERSCRIPT", "USERAGENT", - "COOKIE", "HEADERS", + "PROJECT", ] VERSION_MAJOR = 1 VERSION_MINOR = 8 VERSION_BETA = True ROOT = Path(__file__).resolve().parent.parent.parent +PROJECT = f"XHS-Downloader V{VERSION_MAJOR}.{ +VERSION_MINOR}{" Beta" if VERSION_BETA else ""}" REPOSITORY = "https://github.com/JoeanAmier/XHS-Downloader" LICENCE = "GNU General Public License v3.0" @@ -49,14 +51,8 @@ HEADERS = { "Upgrade-Insecure-Requests": "1", } USERAGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 " - "Safari/537.36 Edg/120.0.0.0") -COOKIE = ( - "abRequestId=a1c55c3d-edcd-5753-938b-15d22a78cb8a; webBuild=3.23.2; " - "a1=18ceecc41c5d2gkprctahn1jayh458m5eoos9grxb50000267832; webId=79879aaf1b46fa2120dfba20d6155928; " - "websectiga=3fff3a6f9f07284b62c0f2ebf91a3b10193175c06e4f71492b60e056edcdebb2; " - "sec_poison_id=52bff38d-96eb-40b6-a46b-5e7cc86014e4; web_session=030037a2ae3713ec49882425e5224a3cbb4eef; " - "gid=yYSddSS2DKdyyYSddSS4ylkFS2fJkTUFS90xlCDIyV0vxM2842Y62j888JKWYqJ8iDD4KY2d; xsecappid=xhs-pc-web") + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 " + "Safari/537.36 Edg/121.0.0.0") MASTER = "b #fff200" PROMPT = "b turquoise2" diff --git a/source/translator/chinese.py b/source/translator/chinese.py index f43913d..7887845 100644 --- a/source/translator/chinese.py +++ b/source/translator/chinese.py @@ -60,7 +60,8 @@ class Chinese: work_path_placeholder: str = "程序根路径" user_agent_placeholder: str = "默认 UA" - cookie_placeholder: str = "内置 Cookie,建议自行设置" + cookie_placeholder_true: str = "小红书网页版 Cookie,无需登录,参数已设置" + cookie_placeholder_false: str = "小红书网页版 Cookie,无需登录,参数未设置" proxy_placeholder: str = "无代理" settings_title: str = "程序设置" @@ -69,6 +70,10 @@ class Chinese: processing: str = "程序处理中..." + monitor_mode: str = "已启动监听剪贴板模式" + monitor_text: str = "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!" + close_monitor: str = "退出监听剪贴板模式" + @staticmethod def request_error(url: str) -> str: return f"网络异常,请求 {url} 失败!" diff --git a/source/translator/english.py b/source/translator/english.py index e1199c9..c005c06 100644 --- a/source/translator/english.py +++ b/source/translator/english.py @@ -78,7 +78,8 @@ class English(Chinese): work_path_placeholder: str = "Program root path" user_agent_placeholder: str = "Default UA" - cookie_placeholder: str = "built-in cookie, it is recommended to set it manually" + cookie_placeholder_true: str = "Xiaohongshu web version cookie, no login required, parameters have been set" + cookie_placeholder_false: str = "Xiaohongshu web version cookie, no login required, parameters not set" proxy_placeholder: str = "No proxy" settings_title: str = "Settings" @@ -87,6 +88,13 @@ class English(Chinese): processing: str = "Processing..." + monitor_mode: str = "Currently in monitoring clipboard mode" + monitor_text: str = ( + "The program will automatically read and extract the link to Xiaohongshu's works from the " + "clipboard, and automatically download the corresponding work file. If you want to close it, " + "please click the close button or write the \"close\" text to the clipboard!") + close_monitor: str = "Exit monitoring clipboard mode" + @staticmethod def request_error(url: str) -> str: return f"Network error, failed to access {url}!" diff --git a/static/XHS-Downloader.tcss b/static/XHS-Downloader.tcss index 3403f06..058748d 100644 --- a/static/XHS-Downloader.tcss +++ b/static/XHS-Downloader.tcss @@ -1,4 +1,4 @@ -ScrollableContainer, RichLog { +ScrollableContainer, RichLog, Monitor { background: #2f3542; } Button { @@ -20,7 +20,7 @@ Button { Button#deal, Button#paste, Button#save { tint: #27ae60 60%; } -Button#reset, Button#abandon { +Button#reset, Button#abandon, Button#close { tint: #c0392b 60%; } Label { @@ -32,7 +32,7 @@ Label { Label.params { margin: 1 0 0 0; } -Label#prompt { +Label#prompt, Label#monitor { padding: 1; } Bar { diff --git a/static/screenshot/程序运行截图CN1.png b/static/screenshot/程序运行截图CN1.png index 32dc6df..13d547c 100644 Binary files a/static/screenshot/程序运行截图CN1.png and b/static/screenshot/程序运行截图CN1.png differ diff --git a/static/screenshot/程序运行截图CN2.png b/static/screenshot/程序运行截图CN2.png index 1d91f2b..7bac5ab 100644 Binary files a/static/screenshot/程序运行截图CN2.png and b/static/screenshot/程序运行截图CN2.png differ diff --git a/static/screenshot/程序运行截图CN3.png b/static/screenshot/程序运行截图CN3.png new file mode 100644 index 0000000..281c5b4 Binary files /dev/null and b/static/screenshot/程序运行截图CN3.png differ diff --git a/static/screenshot/程序运行截图EN1.png b/static/screenshot/程序运行截图EN1.png index 50c497d..0d51c46 100644 Binary files a/static/screenshot/程序运行截图EN1.png and b/static/screenshot/程序运行截图EN1.png differ diff --git a/static/screenshot/程序运行截图EN2.png b/static/screenshot/程序运行截图EN2.png index 5fc2efa..7f83fe1 100644 Binary files a/static/screenshot/程序运行截图EN2.png and b/static/screenshot/程序运行截图EN2.png differ diff --git a/static/screenshot/程序运行截图EN3.png b/static/screenshot/程序运行截图EN3.png new file mode 100644 index 0000000..3bf6138 Binary files /dev/null and b/static/screenshot/程序运行截图EN3.png differ