新增监听剪贴板下载作品功能
11
README.md
@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<br>
|
||||
<p>🔥 <b>小红书链接提取/作品采集工具</b>:提取账号发布、收藏、点赞作品链接;提取搜索结果作品、用户链接;采集小红书作品信息;提取小红书作品下载地址;下载小红书无水印作品文件!</p>
|
||||
<p>❤️ 作者仅在 GitHub 发布 XHS-Downloader,没有任何收费计划,谨防上当受骗!</p>
|
||||
<p>❤️ 作者仅在 GitHub 发布 XHS-Downloader,未与任何个人或网站合作发布,项目没有任何收费计划,谨防上当受骗!</p>
|
||||
<h1>📑 项目功能</h1>
|
||||
<ul><b>程序功能</b>
|
||||
<li>✅ 采集小红书作品信息</li>
|
||||
@ -24,7 +24,7 @@
|
||||
<li>✅ 自定义图文作品文件下载格式</li>
|
||||
<li>✅ 持久化储存作品信息至文件</li>
|
||||
<li>✅ 作品文件储存至单独文件夹</li>
|
||||
<li>☑️ 后台监听剪贴板下载作品</li>
|
||||
<li>✅ 后台监听剪贴板下载作品</li>
|
||||
<li>☑️ 支持 API 调用功能</li>
|
||||
</ul>
|
||||
<ul><b>脚本功能</b>
|
||||
@ -41,6 +41,8 @@
|
||||
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN1.png" alt=""></a>
|
||||
<hr>
|
||||
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN2.png" alt=""></a>
|
||||
<hr>
|
||||
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN3.png" alt=""></a>
|
||||
<h1>🔗 支持链接</h1>
|
||||
<ul>
|
||||
<li><code>https://www.xiaohongshu.com/explore/作品ID</code></li>
|
||||
@ -110,6 +112,7 @@ async with XHS(work_path=work_path,
|
||||
</pre>
|
||||
<h1>⚙️ 配置文件</h1>
|
||||
<p>项目根目录下的 <code>settings.json</code> 文件,首次运行自动生成,可以自定义部分运行参数。</p>
|
||||
<p>建议自行设置 <code>cookie</code> 参数,若不设置该参数,程序功能可能无法正常使用!</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -141,8 +144,8 @@ async with XHS(work_path=work_path,
|
||||
<tr>
|
||||
<td align="center">cookie</td>
|
||||
<td align="center">str</td>
|
||||
<td align="center">小红书网页版 Cookie,<b>无需登录,建议修改</b></td>
|
||||
<td align="center">默认 Cookie</td>
|
||||
<td align="center">小红书网页版 Cookie,<b>无需登录</b></td>
|
||||
<td align="center">无</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">proxy</td>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
62
source/TUI/monitor.py
Normal file
@ -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()
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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} 失败!"
|
||||
|
||||
@ -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}!"
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 76 KiB |
BIN
static/screenshot/程序运行截图CN3.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
BIN
static/screenshot/程序运行截图EN3.png
Normal file
|
After Width: | Height: | Size: 117 KiB |