mirror of
https://github.com/JoeanAmier/XHS-Downloader.git
synced 2026-10-07 16:27:53 +08:00
feat(app): 支持图形用户交互界面
This commit is contained in:
@@ -188,10 +188,10 @@
|
||||
<td align="center">配置文件 proxy 参数</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">skip</td>
|
||||
<td align="center">check_record</td>
|
||||
<td align="center">bool</td>
|
||||
<td align="center">是否跳过存在下载记录的作品;设置为 <code>true</code> 将不会返回存在下载记录的作品数据;可选参数</td>
|
||||
<td align="center">false</td>
|
||||
<td align="center">是否跳过已有下载记录的作品;可选参数</td>
|
||||
<td align="center">true</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+3
-3
@@ -192,10 +192,10 @@
|
||||
<td align="center">Settings proxy Value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">skip</td>
|
||||
<td align="center">check_record</td>
|
||||
<td align="center">bool</td>
|
||||
<td align="center">Whether to skip notes with download records; set to <code>true</code> will not return notes data with download records; Optional parameter</td>
|
||||
<td align="center">false</td>
|
||||
<td align="center">Whether to skip notes with existing download records; Optional parameter</td>
|
||||
<td align="center">true</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,13 +3,11 @@ from asyncio.exceptions import CancelledError
|
||||
from contextlib import suppress
|
||||
from sys import argv
|
||||
|
||||
from source import Settings
|
||||
from source import XHS
|
||||
from source import XHSDownloader
|
||||
from source import cli
|
||||
from source import XHS, Settings, XHSDownloader, cli
|
||||
from source.GUI import launch
|
||||
|
||||
|
||||
async def app():
|
||||
async def tui():
|
||||
async with XHSDownloader() as xhs:
|
||||
await xhs.run_async()
|
||||
|
||||
@@ -47,9 +45,10 @@ if __name__ == "__main__":
|
||||
KeyboardInterrupt,
|
||||
CancelledError,
|
||||
):
|
||||
# TODO: 重构优化
|
||||
if len(argv) == 1:
|
||||
run(app())
|
||||
launch()
|
||||
elif argv[1].upper() == "TUI":
|
||||
run(tui())
|
||||
elif argv[1].upper() == "API":
|
||||
run(api_server())
|
||||
elif argv[1].upper() == "MCP":
|
||||
|
||||
+1
-7
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"httpx[http2,socks]>=0.28.1",
|
||||
"lxml>=6.1.1",
|
||||
"pyperclip>=1.11.0",
|
||||
"pywebview>=6.2.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"textual>=8.2.8",
|
||||
"uvicorn>=0.51.0",
|
||||
@@ -109,10 +110,3 @@ docstring-code-format = false
|
||||
# This only has an effect when the `docstring-code-format` setting is
|
||||
# enabled.
|
||||
docstring-code-line-length = "dynamic"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"nuitka>=4.1.3",
|
||||
"pycryptodome>=3.23.0",
|
||||
"textual-dev>=1.7.0",
|
||||
]
|
||||
|
||||
@@ -18,6 +18,8 @@ lxml==6.1.1
|
||||
# via xhs-downloader (pyproject.toml)
|
||||
pyperclip==1.11.0
|
||||
# via xhs-downloader (pyproject.toml)
|
||||
pywebview>=6.2.1
|
||||
# via xhs-downloader (pyproject.toml)
|
||||
pyyaml==6.0.3
|
||||
# via xhs-downloader (pyproject.toml)
|
||||
textual==8.2.8
|
||||
|
||||
+10
-10
@@ -3,14 +3,14 @@ from contextlib import suppress
|
||||
from pathlib import Path as Root
|
||||
from textwrap import fill
|
||||
|
||||
from click import Context
|
||||
from click import (
|
||||
command,
|
||||
option,
|
||||
Path,
|
||||
Choice,
|
||||
pass_context,
|
||||
Context,
|
||||
Path,
|
||||
command,
|
||||
echo,
|
||||
option,
|
||||
pass_context,
|
||||
)
|
||||
from rich import print
|
||||
from rich.panel import Panel
|
||||
@@ -20,11 +20,11 @@ from source.application import XHS
|
||||
|
||||
# from source.expansion import BrowserCookie
|
||||
from source.module import (
|
||||
ROOT,
|
||||
PROJECT,
|
||||
VOLUME,
|
||||
Settings,
|
||||
)
|
||||
from source.module import Settings
|
||||
from source.translation import switch_language, _
|
||||
from source.translation import _, switch_language
|
||||
|
||||
__all__ = ["cli"]
|
||||
|
||||
@@ -69,8 +69,8 @@ class CLI:
|
||||
|
||||
def __check_settings_path(self) -> Path:
|
||||
if not self.path:
|
||||
return ROOT
|
||||
return s.parent if (s := Root(self.path)).is_file() else ROOT
|
||||
return VOLUME
|
||||
return s.parent if (s := Root(self.path)).is_file() else VOLUME
|
||||
|
||||
@staticmethod
|
||||
def __merge_cookie(data: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""PyWebView GUI 包。"""
|
||||
|
||||
from .launcher import get_index_path, launch
|
||||
|
||||
__all__ = ["get_index_path", "launch"]
|
||||
@@ -0,0 +1,844 @@
|
||||
"""PyWebView 静态界面与下载器核心之间的运行时桥接层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from asyncio import CancelledError
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from re import search
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pyperclip import copy, paste
|
||||
|
||||
from ..application import XHS
|
||||
from ..module import (
|
||||
LICENCE,
|
||||
RELEASES,
|
||||
REPOSITORY,
|
||||
VERSION_BETA,
|
||||
VERSION_MAJOR,
|
||||
VERSION_MINOR,
|
||||
VOLUME,
|
||||
Settings,
|
||||
compare_versions,
|
||||
)
|
||||
from ..translation import _
|
||||
from .ui_strings import (
|
||||
NAME_FORMAT_FIELDS,
|
||||
get_ui_translations,
|
||||
normalize_name_format_field,
|
||||
)
|
||||
|
||||
# 下载记录固定按每页 100 条返回,前端仅负责渲染当前页。
|
||||
HISTORY_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def now_text() -> str:
|
||||
"""返回用于界面显示的本地时间。"""
|
||||
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
|
||||
def build_update_result(release_url: str) -> dict[str, Any]:
|
||||
"""根据 GitHub 重定向后的发布页 URL 构造界面可直接展示的更新结果。"""
|
||||
|
||||
tag = release_url.rstrip("/").split("/")[-1]
|
||||
match = search(r"(?<!\d)(\d+)\.(\d+)(?!\d)", tag)
|
||||
if not match:
|
||||
raise ValueError("Invalid release version")
|
||||
|
||||
target = tuple(map(int, match.groups()))
|
||||
current = (VERSION_MAJOR, VERSION_MINOR)
|
||||
latest_version = f"{target[0]}.{target[1]}"
|
||||
current_version = f"{VERSION_MAJOR}.{VERSION_MINOR}"
|
||||
if VERSION_BETA:
|
||||
current_version += " Beta"
|
||||
|
||||
match compare_versions(
|
||||
f"{current[0]}.{current[1]}",
|
||||
latest_version,
|
||||
VERSION_BETA,
|
||||
):
|
||||
case 4:
|
||||
kind = "update_available"
|
||||
title = _("检测到新版本:{0}.{1}").format(
|
||||
target[0],
|
||||
target[1],
|
||||
)
|
||||
message = _("当前版本为 {0}").format(current_version)
|
||||
case 3:
|
||||
kind = "stable_available"
|
||||
title = _("当前版本为开发版, 可更新至正式版")
|
||||
message = _("{0} 正式版已发布,当前版本为 {1}").format(
|
||||
latest_version, current_version
|
||||
)
|
||||
case 1:
|
||||
kind = "up_to_date"
|
||||
title = _("当前已是最新正式版")
|
||||
message = _("当前版本为 {0}").format(current_version)
|
||||
case 2:
|
||||
kind = "development_current" if VERSION_BETA else "up_to_date"
|
||||
title = _("当前已是最新开发版") if VERSION_BETA else _("当前已是最新正式版")
|
||||
message = _("当前版本为 {0},最新正式版为 {1}").format(
|
||||
current_version, latest_version
|
||||
)
|
||||
case _:
|
||||
raise ValueError
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskState:
|
||||
"""界面任务队列中的单个任务快照。"""
|
||||
|
||||
task_id: str
|
||||
url: str
|
||||
source: str = "manual"
|
||||
state: str = "pending"
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=now_text)
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""转换为可通过 PyWebView 序列化给 JavaScript 的普通字典。"""
|
||||
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"url": self.url,
|
||||
"source": self.source,
|
||||
"state": self.state,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
}
|
||||
|
||||
|
||||
class GuiLogSink:
|
||||
"""把核心下载器的 RichLog 写入接口适配到 GUI 日志缓冲区。"""
|
||||
|
||||
def __init__(self, backend: "GuiBackend"):
|
||||
self.backend = backend
|
||||
|
||||
def write(self, value: Any, scroll_end: bool = True) -> None:
|
||||
# 核心日志包含 Rich 样式;此处仅保留颜色对应的等级,交由前端完成着色。
|
||||
text = getattr(value, "plain", str(value))
|
||||
style = str(getattr(value, "style", ""))
|
||||
if "red" in style:
|
||||
level = "error"
|
||||
elif "yellow" in style:
|
||||
level = "warning"
|
||||
elif "green" in style:
|
||||
level = "success"
|
||||
else:
|
||||
level = "info"
|
||||
self.backend.add_log(text, level)
|
||||
|
||||
|
||||
class GuiBackend:
|
||||
"""维护一个下载器实例和一个长期运行的 asyncio 事件循环。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.loop: asyncio.AbstractEventLoop | None = None
|
||||
self.thread: Thread | None = None
|
||||
self.ready = Event()
|
||||
self.start_error: BaseException | None = None
|
||||
self.closed = False
|
||||
self.settings_manager = Settings(VOLUME)
|
||||
self.settings: dict[str, Any] = {}
|
||||
self.xhs: XHS | None = None
|
||||
self.task_queue: asyncio.Queue[str] | None = None
|
||||
self.worker: asyncio.Task | None = None
|
||||
self.monitor_task: asyncio.Task | None = None
|
||||
self.tasks: dict[str, TaskState] = {}
|
||||
self.files: dict[str, dict[str, Any]] = {}
|
||||
self.history_revision = 0
|
||||
self.logs: deque[dict[str, str]] = deque(maxlen=300)
|
||||
self.monitor: dict[str, Any] = {
|
||||
"active": False,
|
||||
"state": "stopped",
|
||||
"started_at": None,
|
||||
"detected": 0,
|
||||
"created": 0,
|
||||
}
|
||||
|
||||
def start(self) -> None:
|
||||
# PyWebView 的 JavaScript 调用来自主线程,下载器事件循环运行于后台线程,
|
||||
# 以避免网络请求阻塞窗口,并通过 call() 实现跨线程协程调度。
|
||||
self.thread = Thread(
|
||||
target=self._thread_main,
|
||||
name="xhs-gui-backend",
|
||||
daemon=True,
|
||||
)
|
||||
self.thread.start()
|
||||
self.ready.wait()
|
||||
if self.start_error:
|
||||
raise RuntimeError(_("GUI 后端初始化失败")) from self.start_error
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
"""创建后台事件循环,并在初始化完成后持续处理异步任务。"""
|
||||
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
try:
|
||||
self.loop.run_until_complete(self._initialize())
|
||||
except BaseException as error:
|
||||
self.start_error = error
|
||||
self.ready.set()
|
||||
self.loop.close()
|
||||
return
|
||||
self.ready.set()
|
||||
self.loop.run_forever()
|
||||
self.loop.close()
|
||||
|
||||
async def _initialize(self) -> None:
|
||||
"""读取配置;用户已同意免责声明后才创建下载器运行时。"""
|
||||
|
||||
self.settings = self.settings_manager.run()
|
||||
if not self.settings["disclaimer_accepted"]:
|
||||
return
|
||||
await self._start_runtime()
|
||||
|
||||
async def _start_runtime(self) -> None:
|
||||
"""创建下载器和任务 worker;启动时不自动创建下载任务。"""
|
||||
|
||||
if self.xhs:
|
||||
return
|
||||
self.task_queue = asyncio.Queue()
|
||||
await self._create_xhs()
|
||||
self.worker = asyncio.create_task(self._worker_loop())
|
||||
self.add_log(_("GUI 后端初始化完成"), "success")
|
||||
|
||||
async def _create_xhs(self) -> None:
|
||||
"""按当前配置创建 XHS 实例,并把核心日志导向 GUI。"""
|
||||
|
||||
self.xhs = XHS(**self.settings, _print=False)
|
||||
self.xhs.print.func = GuiLogSink(self)
|
||||
await self.xhs.__aenter__()
|
||||
|
||||
async def _close_xhs(self) -> None:
|
||||
"""释放下载器的 HTTP 客户端和其他异步资源。"""
|
||||
|
||||
if self.xhs:
|
||||
await self.xhs.__aexit__(None, None, None)
|
||||
self.xhs = None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从主线程请求后台协程停止,然后关闭事件循环线程。"""
|
||||
|
||||
if not self.loop or not self.thread:
|
||||
return
|
||||
future = asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop)
|
||||
with suppress(Exception):
|
||||
future.result(timeout=15)
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
self.thread.join(timeout=15)
|
||||
self.closed = True
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
"""停止剪贴板监听和任务 worker,再关闭下载器。"""
|
||||
|
||||
if self.monitor_task:
|
||||
self.monitor_task.cancel()
|
||||
with suppress(CancelledError):
|
||||
await self.monitor_task
|
||||
self.monitor_task = None
|
||||
if self.worker:
|
||||
self.worker.cancel()
|
||||
with suppress(CancelledError):
|
||||
await self.worker
|
||||
self.worker = None
|
||||
await self._close_xhs()
|
||||
|
||||
def call(self, coroutine):
|
||||
"""把协程提交到后台循环,并同步等待 PyWebView API 的返回值。"""
|
||||
|
||||
if not self.loop or self.closed:
|
||||
raise RuntimeError(_("GUI 后端未运行"))
|
||||
return asyncio.run_coroutine_threadsafe(coroutine, self.loop).result(timeout=30)
|
||||
|
||||
def add_log(self, message: str, level: str = "info") -> None:
|
||||
"""追加一条有上限的运行日志,避免长期运行时内存无限增长。"""
|
||||
|
||||
self.logs.append(
|
||||
{
|
||||
"time": now_text(),
|
||||
"level": level,
|
||||
"message": str(message),
|
||||
}
|
||||
)
|
||||
|
||||
async def _enqueue_links(
|
||||
self,
|
||||
content: str,
|
||||
source: str,
|
||||
) -> list[str]:
|
||||
"""提取文本中的作品链接,为每个链接建立 pending 任务并放入队列。"""
|
||||
|
||||
if not self.xhs or not self.task_queue:
|
||||
return []
|
||||
links = await self.xhs.extract_links(content)
|
||||
ids = []
|
||||
for link in links:
|
||||
task_id = uuid4().hex
|
||||
self.tasks[task_id] = TaskState(
|
||||
task_id,
|
||||
link,
|
||||
source=source,
|
||||
)
|
||||
self.task_queue.put_nowait(task_id)
|
||||
ids.append(task_id)
|
||||
return ids
|
||||
|
||||
async def create_tasks(
|
||||
self,
|
||||
content: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""创建手动任务并返回本次新建的任务;完整队列由状态快照提供。"""
|
||||
|
||||
ids = await self._enqueue_links(content, "manual")
|
||||
if not ids:
|
||||
self.add_log(_("提取小红书作品链接失败"), "warning")
|
||||
return [self.tasks[i].as_dict() for i in ids]
|
||||
|
||||
async def _worker_loop(self) -> None:
|
||||
"""串行消费任务队列;任务进入 processing 后不再允许取消。"""
|
||||
|
||||
while True:
|
||||
task_id = await self.task_queue.get()
|
||||
try:
|
||||
task = self.tasks.get(task_id)
|
||||
if task and task.state != "cancelled":
|
||||
await self._process_task(task)
|
||||
finally:
|
||||
self.task_queue.task_done()
|
||||
|
||||
async def _process_task(self, task: TaskState) -> None:
|
||||
"""执行单个作品的跳过判断、提取和下载,并更新最终状态。"""
|
||||
|
||||
if task.state != "pending":
|
||||
return
|
||||
task.state = "processing"
|
||||
task.started_at = now_text()
|
||||
try:
|
||||
if not self.xhs:
|
||||
raise RuntimeError(_("下载器尚未初始化"))
|
||||
# extract() 通过回调返回汇总统计,GUI 不根据 URL 推断任务结果。
|
||||
statistics: dict[str, Any] = {}
|
||||
|
||||
def capture_statistics(value: dict[str, Any]) -> None:
|
||||
statistics.update(value)
|
||||
|
||||
await self.xhs.extract(
|
||||
task.url,
|
||||
True,
|
||||
check_record=True,
|
||||
progress_callback=self.on_file_progress,
|
||||
task_id=task.task_id,
|
||||
result_callback=capture_statistics,
|
||||
)
|
||||
if statistics.get("success", 0):
|
||||
task.state = "success"
|
||||
elif statistics.get("skip", 0):
|
||||
task.state = "skipped"
|
||||
else:
|
||||
task.state = "failed"
|
||||
if task.state == "failed":
|
||||
task.error = _("作品处理失败")
|
||||
if self.xhs.manager.download_record:
|
||||
self.history_revision += 1
|
||||
except CancelledError:
|
||||
task.state = "cancelled"
|
||||
self.cancel_task_files(task.task_id)
|
||||
raise
|
||||
except Exception as error:
|
||||
task.state = "failed"
|
||||
task.error = str(error)
|
||||
self.add_log(task.error, "error")
|
||||
finally:
|
||||
task.finished_at = now_text()
|
||||
|
||||
def on_file_progress(self, event: dict[str, Any]) -> None:
|
||||
"""接收下载器的实时文件事件,按“任务 ID + 文件名”更新进度。"""
|
||||
|
||||
task_id = event.get("task_id") or "unknown"
|
||||
filename = event.get("filename") or "unknown"
|
||||
file_id = f"{task_id}:{filename}"
|
||||
item = self.files.setdefault(
|
||||
file_id,
|
||||
{
|
||||
"file_id": file_id,
|
||||
"task_id": task_id,
|
||||
"filename": filename,
|
||||
},
|
||||
)
|
||||
item.update(
|
||||
{
|
||||
"completed_bytes": event.get("completed_bytes", 0),
|
||||
"total_bytes": event.get("total_bytes"),
|
||||
"state": event.get("state", "downloading"),
|
||||
}
|
||||
)
|
||||
|
||||
def cancel_task_files(self, task_id: str) -> None:
|
||||
"""任务被取消或终止时,把仍在下载的文件标记为 cancelled。"""
|
||||
|
||||
for item in self.files.values():
|
||||
if item.get("task_id") == task_id and item.get("state") == "downloading":
|
||||
item["state"] = "cancelled"
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""只允许取消尚未开始的 pending 任务,processing 任务不会被中断。"""
|
||||
|
||||
task = self.tasks.get(task_id)
|
||||
if not task or task.state != "pending":
|
||||
return False
|
||||
task.state = "cancelled"
|
||||
task.finished_at = now_text()
|
||||
return True
|
||||
|
||||
async def clear_finished(self) -> int:
|
||||
"""移除已结束任务及其文件快照,并返回移除数量。"""
|
||||
|
||||
finished = {"success", "failed", "skipped", "cancelled"}
|
||||
removed = [
|
||||
task_id for task_id, task in self.tasks.items() if task.state in finished
|
||||
]
|
||||
for task_id in removed:
|
||||
self.tasks.pop(task_id, None)
|
||||
for file_id, item in list(self.files.items()):
|
||||
if item.get("task_id") in removed:
|
||||
self.files.pop(file_id, None)
|
||||
return len(removed)
|
||||
|
||||
async def start_monitor(self) -> dict[str, Any]:
|
||||
"""清空剪贴板后启动监听;监听创建的任务与手动任务共用同一队列。"""
|
||||
|
||||
if self.monitor_task and not self.monitor_task.done():
|
||||
return self.monitor
|
||||
await asyncio.to_thread(copy, "")
|
||||
self.monitor.update(
|
||||
{
|
||||
"active": True,
|
||||
"state": "running",
|
||||
"started_at": now_text(),
|
||||
"detected": 0,
|
||||
"created": 0,
|
||||
}
|
||||
)
|
||||
self.monitor_task = asyncio.create_task(self._monitor_loop())
|
||||
self.add_log(_("已启动监听剪贴板模式"), "success")
|
||||
return self.monitor
|
||||
|
||||
async def stop_monitor(self) -> dict[str, Any]:
|
||||
"""停止读取剪贴板,但不影响已经进入任务队列的作品继续处理。"""
|
||||
|
||||
if self.monitor_task:
|
||||
self.monitor_task.cancel()
|
||||
with suppress(CancelledError):
|
||||
await self.monitor_task
|
||||
self.monitor_task = None
|
||||
self.monitor.update({"active": False, "state": "stopped"})
|
||||
self.add_log(_("已停止读取剪贴板,队列中的任务继续处理"), "info")
|
||||
return self.monitor
|
||||
|
||||
async def _monitor_loop(self) -> None:
|
||||
"""每秒读取一次剪贴板,只处理自上次读取后出现的新内容。"""
|
||||
|
||||
clipboard_cache = ""
|
||||
while True:
|
||||
try:
|
||||
content = await asyncio.to_thread(paste)
|
||||
except Exception as error:
|
||||
self.add_log(f"{_('读取剪贴板失败')}:{error}", "warning")
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
if content and content != clipboard_cache:
|
||||
clipboard_cache = content
|
||||
created = await self._enqueue_links(content, "monitor")
|
||||
self.monitor["detected"] += len(created)
|
||||
self.monitor["created"] += len(created)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def read_clipboard(self) -> str:
|
||||
"""在线程池中读取系统剪贴板,避免阻塞 asyncio 事件循环。"""
|
||||
|
||||
return await asyncio.to_thread(paste)
|
||||
|
||||
async def get_history_page(self, query: str = "", page: int = 1) -> dict[str, Any]:
|
||||
"""从数据库查询指定页的作品 ID;搜索在数据库端执行而非前端过滤。"""
|
||||
|
||||
enabled = self.xhs.manager.download_record if self.xhs else False
|
||||
query = str(query or "").strip()[:200]
|
||||
try:
|
||||
requested_page = max(1, int(page))
|
||||
except (TypeError, ValueError):
|
||||
requested_page = 1
|
||||
if not enabled or not self.xhs:
|
||||
return {
|
||||
"enabled": False,
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 0,
|
||||
"page_size": HISTORY_PAGE_SIZE,
|
||||
"page_count": 0,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
recorder = self.xhs.id_recorder
|
||||
total = await recorder.count(query)
|
||||
page_count = (total + HISTORY_PAGE_SIZE - 1) // HISTORY_PAGE_SIZE
|
||||
current_page = min(requested_page, page_count) if page_count else 0
|
||||
items = await recorder.page(
|
||||
query,
|
||||
HISTORY_PAGE_SIZE,
|
||||
(current_page - 1) * HISTORY_PAGE_SIZE if current_page else 0,
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": current_page,
|
||||
"page_size": HISTORY_PAGE_SIZE,
|
||||
"page_count": page_count,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
async def delete_history_records(
|
||||
self,
|
||||
ids: list[str],
|
||||
query: str = "",
|
||||
page: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""删除界面当前选中的作品 ID 记录,并返回删除后的当前查询页。"""
|
||||
|
||||
if self.xhs and self.xhs.manager.download_record:
|
||||
values = list(dict.fromkeys(str(i).strip() for i in ids if str(i).strip()))
|
||||
if values:
|
||||
await self.xhs.id_recorder.delete(values)
|
||||
self.history_revision += 1
|
||||
self.add_log(_("删除下载记录成功"), "info")
|
||||
return await self.get_history_page(query, page)
|
||||
|
||||
async def get_settings(self) -> dict[str, Any]:
|
||||
"""返回当前配置及实际生效的保存根路径。"""
|
||||
|
||||
return self._settings_payload()
|
||||
|
||||
async def get_translations(self) -> dict[str, Any]:
|
||||
"""从 GUI 文案模块获取当前语言的完整语言包。"""
|
||||
|
||||
language = self.settings["language"]
|
||||
return {
|
||||
"language": language,
|
||||
"messages": get_ui_translations(language),
|
||||
"name_fields": list(NAME_FORMAT_FIELDS),
|
||||
}
|
||||
|
||||
async def get_disclaimer(self) -> dict[str, bool]:
|
||||
"""返回免责声明确认状态。"""
|
||||
|
||||
return {
|
||||
"accepted": self.settings["disclaimer_accepted"],
|
||||
}
|
||||
|
||||
async def accept_disclaimer(self) -> dict[str, bool]:
|
||||
"""记录用户已同意免责声明,并在首次同意后启动下载器运行时。"""
|
||||
|
||||
if not self.settings["disclaimer_accepted"]:
|
||||
self.settings["disclaimer_accepted"] = True
|
||||
self.settings_manager.update(self.settings)
|
||||
await self._start_runtime()
|
||||
return {"accepted": True}
|
||||
|
||||
def _settings_payload(self, values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""整理配置给前端;命名字段始终使用 Manager 的中文字段值。"""
|
||||
|
||||
payload = dict(self.settings if values is None else values)
|
||||
configured_path = str(payload.get("work_path") or "").strip()
|
||||
if values is None and self.xhs:
|
||||
manager = self.xhs.manager
|
||||
# Manager 是运行时配置的唯一归一化入口,界面回显以实际生效值为准。
|
||||
payload.update(
|
||||
{
|
||||
"work_path": str(manager.path),
|
||||
"folder_name": manager.folder.name,
|
||||
"name_format": manager.name_format,
|
||||
"chunk": manager.chunk,
|
||||
"timeout": manager.timeout,
|
||||
"max_retry": manager.retry,
|
||||
"image_format": manager.image_format,
|
||||
"video_preference": manager.video_preference,
|
||||
"proxy": manager.proxy,
|
||||
"user_agent": manager.blank_headers.get("user-agent", ""),
|
||||
"record_data": manager.record_data,
|
||||
"folder_mode": manager.folder_mode,
|
||||
"download_record": manager.download_record,
|
||||
"image_download": manager.image_download,
|
||||
"video_download": manager.video_download,
|
||||
"live_download": manager.live_download,
|
||||
"author_archive": manager.author_archive,
|
||||
"write_mtime": manager.write_mtime,
|
||||
"script_server": manager.script_server,
|
||||
"note_format": manager.note_format,
|
||||
}
|
||||
)
|
||||
effective_path = manager.path
|
||||
else:
|
||||
effective_path = Path(configured_path) if configured_path else VOLUME
|
||||
payload["effective_work_path"] = str(effective_path.resolve())
|
||||
return payload
|
||||
|
||||
async def save_settings(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""保存配置并重建下载器,使 Manager 处理后的新配置立即生效。"""
|
||||
|
||||
if any(task.state == "processing" for task in self.tasks.values()):
|
||||
return {"ok": False, "error": _("请等待正在处理的任务结束后再保存配置")}
|
||||
if self.monitor_task and not self.monitor_task.done():
|
||||
return {"ok": False, "error": _("请先关闭剪贴板监听再保存配置")}
|
||||
# 仅接收 Settings.default 中定义的字段,避免前端额外数据进入配置。
|
||||
updated = dict(self.settings)
|
||||
updated.update(
|
||||
{key: value for key, value in data.items() if key in Settings.default}
|
||||
)
|
||||
# 英文字段名称可能包含空格,GUI 以数组提交;旧配置字符串仍按项目格式读取。
|
||||
configured_name_format = updated.get("name_format") or ""
|
||||
fields = (
|
||||
configured_name_format
|
||||
if isinstance(configured_name_format, list)
|
||||
else configured_name_format.split()
|
||||
)
|
||||
name_fields = [normalize_name_format_field(field) for field in fields]
|
||||
updated["name_format"] = " ".join(dict.fromkeys(name_fields))
|
||||
self.settings_manager.update(updated)
|
||||
await self._close_xhs()
|
||||
self.settings = updated
|
||||
await self._create_xhs()
|
||||
self.history_revision += 1
|
||||
self.add_log(_("配置已保存,下载器已重新加载"), "success")
|
||||
return {"ok": True, "settings": self._settings_payload()}
|
||||
|
||||
async def check_update(self) -> dict[str, Any]:
|
||||
"""请求 GitHub 发布页并比较版本,返回成功或可展示的错误结果。"""
|
||||
|
||||
if not self.xhs:
|
||||
return {"status": "error", "message": _("下载器尚未初始化")}
|
||||
try:
|
||||
async with self.xhs.html.client.stream(
|
||||
"GET",
|
||||
RELEASES,
|
||||
timeout=8,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return build_update_result(str(response.url))
|
||||
except Exception as error:
|
||||
self.add_log(f"{_('检测新版本失败')}:{error}", "error")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": _("无法获取最新版本,请检查网络连接后重试"),
|
||||
}
|
||||
|
||||
async def open_repository(self) -> bool:
|
||||
"""在线程池中调用系统浏览器打开项目仓库。"""
|
||||
|
||||
import webbrowser
|
||||
|
||||
return await asyncio.to_thread(webbrowser.open, REPOSITORY)
|
||||
|
||||
async def open_download_folder(self) -> bool:
|
||||
"""打开当前配置实际生效的作品文件储存目录。"""
|
||||
|
||||
if not self.xhs:
|
||||
return False
|
||||
|
||||
def open_path(path: Path) -> bool:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if sys.platform == "win32":
|
||||
from os import startfile
|
||||
|
||||
startfile(str(path))
|
||||
else:
|
||||
from subprocess import DEVNULL, Popen
|
||||
|
||||
command = "open" if sys.platform == "darwin" else "xdg-open"
|
||||
Popen([command, str(path)], stdout=DEVNULL, stderr=DEVNULL)
|
||||
return True
|
||||
except Exception as error:
|
||||
self.add_log(f"{_('无法打开下载文件夹')}:{error}", "error")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(open_path, self.xhs.manager.folder.resolve())
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
"""生成前端轮询所需的完整只读状态快照。"""
|
||||
|
||||
tasks = [task.as_dict() for task in self.tasks.values()]
|
||||
history_enabled = self.xhs.manager.download_record if self.xhs else False
|
||||
return {
|
||||
"disclaimer_accepted": self.settings["disclaimer_accepted"],
|
||||
"tasks": tasks,
|
||||
"files": list(self.files.values()),
|
||||
"history_enabled": history_enabled,
|
||||
"history_revision": self.history_revision,
|
||||
"logs": list(self.logs),
|
||||
"monitor": dict(self.monitor),
|
||||
"settings": self._settings_payload(),
|
||||
"about": {
|
||||
"version": f"{VERSION_MAJOR}.{VERSION_MINOR}",
|
||||
"release": "Beta" if VERSION_BETA else "Stable",
|
||||
"license": LICENCE,
|
||||
"repository": REPOSITORY,
|
||||
"author": "JoeanAmier",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class GuiApi:
|
||||
"""PyWebView 暴露的同步 API,内部统一转发到后台 asyncio 循环。"""
|
||||
|
||||
def __init__(self, backend: GuiBackend):
|
||||
# PyWebView 会递归暴露 js_api 的公共属性;实现对象保持私有,
|
||||
# 避免创建 JavaScript 桥接时遍历到后端内部控制对象。
|
||||
self._backend = backend
|
||||
self._window: Any | None = None
|
||||
|
||||
def _bind_window(self, window: Any) -> None:
|
||||
"""保存窗口对象,供目录选择对话框使用。"""
|
||||
|
||||
self._window = window
|
||||
|
||||
def get_state(self) -> dict[str, Any]:
|
||||
"""获取一次前端状态快照。"""
|
||||
|
||||
return self._backend.call(self._backend.snapshot())
|
||||
|
||||
def paste_content(self) -> str:
|
||||
"""读取剪贴板文本。"""
|
||||
|
||||
return self._backend.call(self._backend.read_clipboard())
|
||||
|
||||
def browse_directory(self, directory: str = "") -> str:
|
||||
"""打开系统目录选择器并返回用户选中的路径。"""
|
||||
|
||||
if not self._window:
|
||||
raise RuntimeError(_("GUI 窗口尚未就绪"))
|
||||
import webview
|
||||
|
||||
initial = Path(str(directory or ""))
|
||||
dialog_directory = str(initial) if initial.is_dir() else ""
|
||||
file_dialog = getattr(webview, "FileDialog", None)
|
||||
dialog_type = file_dialog.FOLDER if file_dialog else webview.FOLDER_DIALOG
|
||||
selected = self._window.create_file_dialog(
|
||||
dialog_type,
|
||||
directory=dialog_directory,
|
||||
allow_multiple=False,
|
||||
)
|
||||
return str(selected[0]) if selected else ""
|
||||
|
||||
def create_tasks(
|
||||
self,
|
||||
content: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""通过桥接创建任务。"""
|
||||
|
||||
return self._backend.call(self._backend.create_tasks(content))
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""取消一个仍处于 pending 状态的任务。"""
|
||||
|
||||
return self._backend.call(self._backend.cancel_task(task_id))
|
||||
|
||||
def clear_finished(self) -> int:
|
||||
"""清理已完成、失败、跳过或取消的任务。"""
|
||||
|
||||
return self._backend.call(self._backend.clear_finished())
|
||||
|
||||
def start_monitor(self) -> dict[str, Any]:
|
||||
"""启动剪贴板监听模式。"""
|
||||
|
||||
return self._backend.call(self._backend.start_monitor())
|
||||
|
||||
def stop_monitor(self) -> dict[str, Any]:
|
||||
"""停止剪贴板监听,但保留已创建的队列任务。"""
|
||||
|
||||
return self._backend.call(self._backend.stop_monitor())
|
||||
|
||||
def get_history_page(self, query: str = "", page: int = 1) -> dict[str, Any]:
|
||||
"""查询下载记录的指定页。"""
|
||||
|
||||
return self._backend.call(self._backend.get_history_page(query, page))
|
||||
|
||||
def delete_history_records(
|
||||
self,
|
||||
ids: list[str],
|
||||
query: str = "",
|
||||
page: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""删除界面选中的下载记录。"""
|
||||
|
||||
return self._backend.call(
|
||||
self._backend.delete_history_records(ids, query, page)
|
||||
)
|
||||
|
||||
def get_settings(self) -> dict[str, Any]:
|
||||
"""读取当前配置。"""
|
||||
|
||||
return self._backend.call(self._backend.get_settings())
|
||||
|
||||
def get_translations(self) -> dict[str, Any]:
|
||||
"""读取当前语言的 GUI 文案。"""
|
||||
|
||||
return self._backend.call(self._backend.get_translations())
|
||||
|
||||
def get_disclaimer(self) -> dict[str, bool]:
|
||||
"""读取免责声明确认状态。"""
|
||||
|
||||
return self._backend.call(self._backend.get_disclaimer())
|
||||
|
||||
def accept_disclaimer(self) -> dict[str, bool]:
|
||||
"""确认免责声明并初始化 GUI 后端运行时。"""
|
||||
|
||||
return self._backend.call(self._backend.accept_disclaimer())
|
||||
|
||||
def decline_disclaimer(self) -> bool:
|
||||
"""不同意免责声明时关闭 GUI 窗口。"""
|
||||
|
||||
if not self._window:
|
||||
return False
|
||||
self._window.destroy()
|
||||
return True
|
||||
|
||||
def save_settings(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""保存 GUI 提交的配置。"""
|
||||
|
||||
return self._backend.call(self._backend.save_settings(data))
|
||||
|
||||
def check_update(self) -> dict[str, Any]:
|
||||
"""检查远端版本并返回可展示的结果。"""
|
||||
|
||||
return self._backend.call(self._backend.check_update())
|
||||
|
||||
def open_repository(self) -> bool:
|
||||
"""请求系统浏览器打开项目仓库。"""
|
||||
|
||||
return self._backend.call(self._backend.open_repository())
|
||||
|
||||
def open_download_folder(self) -> bool:
|
||||
"""请求系统文件管理器打开当前下载目录。"""
|
||||
|
||||
return self._backend.call(self._backend.open_download_folder())
|
||||
@@ -0,0 +1,79 @@
|
||||
"""启动 GUI 及其 PyWebView 下载器桥接。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..module import PROJECT, ROOT
|
||||
from .backend import GuiApi, GuiBackend
|
||||
|
||||
# GUI 静态资源与 Python 桥接代码分离,统一从项目 static 目录加载。
|
||||
INDEX_PATH = ROOT.joinpath("static", "GUI", "index.html")
|
||||
|
||||
|
||||
def get_index_path() -> Path:
|
||||
"""返回 GUI 入口 HTML 的绝对路径,并在文件缺失时尽早报错。"""
|
||||
|
||||
if not INDEX_PATH.is_file():
|
||||
raise FileNotFoundError(f"GUI entry document was not found: {INDEX_PATH}")
|
||||
return INDEX_PATH
|
||||
|
||||
|
||||
def get_icon_path(platform_name: str | None = None) -> Path:
|
||||
"""根据桌面平台选择对应的窗口图标格式。"""
|
||||
|
||||
platform_name = platform_name or sys.platform
|
||||
suffix = {
|
||||
"win32": ".ico",
|
||||
"darwin": ".icns",
|
||||
}.get(platform_name, ".png")
|
||||
icon_path = ROOT.joinpath("static", f"XHS-Downloader{suffix}")
|
||||
if not icon_path.is_file():
|
||||
raise FileNotFoundError(f"GUI icon was not found: {icon_path}")
|
||||
return icon_path
|
||||
|
||||
|
||||
def launch() -> None:
|
||||
"""创建并启动静态 GUI 窗口;窗口关闭后再释放后台下载器。"""
|
||||
|
||||
try:
|
||||
import webview
|
||||
except ImportError as error:
|
||||
raise RuntimeError(
|
||||
"PyWebView is required to launch the GUI. "
|
||||
"Install project dependencies first (for example: pip install -e .)."
|
||||
) from error
|
||||
|
||||
# 先启动后端,确保窗口加载完成后 JavaScript 可以立即调用 API。
|
||||
backend = GuiBackend()
|
||||
backend.start()
|
||||
index_url = get_index_path().as_uri()
|
||||
icon_path = get_icon_path()
|
||||
api = GuiApi(backend)
|
||||
# 1280x720 为桌面端默认及最小尺寸,前端 CSS 负责更窄窗口的响应式布局。
|
||||
window = webview.create_window(
|
||||
PROJECT,
|
||||
index_url,
|
||||
width=1280,
|
||||
height=720,
|
||||
min_size=(1280, 720),
|
||||
resizable=True,
|
||||
text_select=True,
|
||||
js_api=api,
|
||||
)
|
||||
api._bind_window(window)
|
||||
try:
|
||||
webview.start(
|
||||
icon=str(icon_path),
|
||||
)
|
||||
finally:
|
||||
backend.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""``python -m source.GUI.launcher`` 使用的命令行入口。"""
|
||||
|
||||
launch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,256 @@
|
||||
from ..module import Manager
|
||||
from ..translation import _, switch_language
|
||||
|
||||
# 记录 GUI 最近一次加载的语言,避免核心和 GUI 连续设置同一语言时重复切换。
|
||||
_UI_LANGUAGE: str | None = None
|
||||
|
||||
# 文件命名字段的后端固定值由 Manager 统一维护,GUI 只维护英文显示名称。
|
||||
NAME_FORMAT_FIELDS = Manager.NAME_KEYS
|
||||
NAME_FORMAT_ENGLISH_LABELS = (
|
||||
"Favorite Count",
|
||||
"Comment Count",
|
||||
"Share Count",
|
||||
"Like Count",
|
||||
"Note Tags",
|
||||
"Note ID",
|
||||
"Note Title",
|
||||
"Note Desc",
|
||||
"Note Type",
|
||||
"Publish Time",
|
||||
"Update Time",
|
||||
"Nickname",
|
||||
"Author ID",
|
||||
)
|
||||
|
||||
# 英文界面提交字段名称时,后端将其还原为 Manager 使用的中文字段值。
|
||||
NAME_FORMAT_VALUES = dict(zip(NAME_FORMAT_ENGLISH_LABELS, NAME_FORMAT_FIELDS))
|
||||
|
||||
|
||||
def get_ui_messages() -> dict[str, str]:
|
||||
"""使用当前翻译器生成 GUI 文本字典。"""
|
||||
|
||||
return {
|
||||
"nav.new_task": _("创建任务"),
|
||||
"nav.queue": _("处理队列"),
|
||||
"nav.monitor": _("剪贴板模式"),
|
||||
"nav.history": _("下载记录"),
|
||||
"nav.logs": _("运行日志"),
|
||||
"nav.settings": _("程序设置"),
|
||||
"nav.about": _("关于项目"),
|
||||
"page.new_task": _("输入一个或多个小红书作品链接,并加入处理队列"),
|
||||
"page.queue": _("按状态筛选任务,并查看每个文件的实时下载进度"),
|
||||
"page.logs": _("查看当前运行期间的处理与下载日志"),
|
||||
"page.settings": _("配置下载内容、文件保存、归档记录和网络请求"),
|
||||
"task.link": _("小红书作品链接"),
|
||||
"task.link_placeholder": _("请输入小红书图文/视频作品链接"),
|
||||
"task.paste": _("读取剪贴板"),
|
||||
"task.paste_process": _("粘贴内容并处理"),
|
||||
"task.clear": _("清空输入框"),
|
||||
"task.start": _("开始处理"),
|
||||
"task.queue_empty": _("任务队列为空"),
|
||||
"task.view_queue": _("查看处理队列"),
|
||||
"monitor.description": _(
|
||||
"持续读取剪贴板,将新出现的小红书作品链接加入任务队列;停止监听不影响已创建任务"
|
||||
),
|
||||
"monitor.reading": _(
|
||||
"正在读取剪贴板中的新链接;停止监听后,已创建任务继续处理"
|
||||
),
|
||||
"monitor.start": _("开启监听"),
|
||||
"monitor.stop": _("关闭监听"),
|
||||
"monitor.status": _("运行状态"),
|
||||
"monitor.inactive": _("未开启"),
|
||||
"monitor.active": _("监听中"),
|
||||
"monitor.detected": _("本次识别"),
|
||||
"monitor.created": _("已创建任务"),
|
||||
"monitor.queue": _("监听任务队列"),
|
||||
"monitor.queue_empty": _("监听任务队列为空"),
|
||||
"queue.clear_finished": _("清理已结束任务"),
|
||||
"queue.open_folder": _("打开下载文件夹"),
|
||||
"queue.pending": _("待处理"),
|
||||
"queue.processing": _("处理中"),
|
||||
"queue.success": _("成功"),
|
||||
"queue.failed": _("失败"),
|
||||
"queue.skipped": _("已跳过"),
|
||||
"queue.cancelled": _("已取消"),
|
||||
"queue.all": _("全部"),
|
||||
"queue.current_filter_empty": _("当前筛选结果为空"),
|
||||
"download.files": _("文件下载"),
|
||||
"download.empty": _("暂无文件下载"),
|
||||
"download.unknown_size": _("未知大小"),
|
||||
"unit.item": _("项"),
|
||||
"unit.file": _("个文件"),
|
||||
"unit.link": _("个链接"),
|
||||
"history.summary": _("共 {0} 条记录"),
|
||||
"history.delete_selected": _("删除选中"),
|
||||
"history.search_placeholder": _("搜索作品 ID"),
|
||||
"history.select_page": _("全选本页"),
|
||||
"history.invert_page": _("反选本页"),
|
||||
"history.selected": _("已选 {0} 条"),
|
||||
"history.empty": _("暂无下载记录"),
|
||||
"history.no_records": _("暂无记录"),
|
||||
"history.previous": _("上一页"),
|
||||
"history.next": _("下一页"),
|
||||
"history.disabled": _("下载记录功能已关闭"),
|
||||
"history.disabled_description": _("在程序设置中开启“作品下载记录开关”后可用"),
|
||||
"history.query_id": _("查询 ExploreID.db 中成功下载的作品 ID"),
|
||||
"history.query_disabled": _("下载记录已关闭,当前不可用"),
|
||||
"history.found": _("找到 {0} 条记录"),
|
||||
"history.range": _("第 {0}–{1} 条,共 {2} 条{3}"),
|
||||
"history.search_results": _("搜索结果"),
|
||||
"history.no_match": _("未找到匹配的作品 ID"),
|
||||
"history.read_failed": _("读取下载记录失败"),
|
||||
"history.deleted_selected": _("已删除 {0} 条下载记录"),
|
||||
"settings.discard": _("放弃更改"),
|
||||
"settings.save": _("保存配置"),
|
||||
"settings.download": _("下载与保存"),
|
||||
"settings.format": _("下载内容与格式"),
|
||||
"settings.archive": _("归档与记录"),
|
||||
"settings.network": _("网络与请求"),
|
||||
"settings.general": _("通用"),
|
||||
"settings.work_path": _("作品数据 / 文件保存根路径"),
|
||||
"settings.browse": _("浏览"),
|
||||
"settings.work_path_help": _(
|
||||
"作品文件储存文件夹会在此路径下创建;留空时使用程序的 Volume 目录"
|
||||
),
|
||||
"settings.folder_name": _("作品文件储存文件夹名称"),
|
||||
"settings.folder_name_help": _("作品文件保存在“保存根路径/文件夹名称”目录中"),
|
||||
"settings.name_format": _("作品文件名称格式"),
|
||||
"settings.name_format_help": _("启用字段按当前顺序以下划线连接为作品文件名"),
|
||||
"settings.enabled_fields": _("启用字段"),
|
||||
"settings.disabled_fields": _("未启用字段"),
|
||||
"settings.image_download": _("图文作品下载开关"),
|
||||
"settings.image_download_help": _("下载图文和图集作品中的图片文件"),
|
||||
"settings.video_download": _("视频作品下载开关"),
|
||||
"settings.video_download_help": _("控制视频作品文件是否下载"),
|
||||
"settings.live_download": _("动图文件下载开关"),
|
||||
"settings.live_download_help": _(
|
||||
"控制图文作品中的动图文件是否下载,需同时开启图文作品下载"
|
||||
),
|
||||
"settings.image_format": _("图片下载格式"),
|
||||
"settings.image_format_help": _("指定图文、图集作品图片的下载格式"),
|
||||
"settings.video_preference": _("视频下载偏好"),
|
||||
"settings.video_preference_resolution": _("分辨率优先"),
|
||||
"settings.video_preference_bitrate": _("码率优先"),
|
||||
"settings.video_preference_size": _("文件大小优先"),
|
||||
"settings.video_preference_help": _(
|
||||
"作品提供多个视频资源时,按所选指标从高到低选择一个文件"
|
||||
),
|
||||
"settings.note_format": _("作品信息保存格式"),
|
||||
"settings.note_format_none": _("不保存"),
|
||||
"settings.note_format_all": _("Markdown 与 TXT"),
|
||||
"settings.note_format_help": _("在作品文件目录中保存该作品的采集信息文件"),
|
||||
"settings.folder_mode": _("作品归档保存模式"),
|
||||
"settings.folder_mode_help": _(
|
||||
"以生成的作品文件名创建目录,再保存该作品的全部文件"
|
||||
),
|
||||
"settings.author_archive": _("作者归档保存模式"),
|
||||
"settings.author_archive_help": _(
|
||||
"先按“作者ID_作者昵称”建立作者子目录;同时开启作品归档时,作品目录位于作者目录下"
|
||||
),
|
||||
"settings.download_record": _("作品下载记录开关"),
|
||||
"settings.download_record_help": _(
|
||||
"成功下载的作品 ID 写入程序 Volume 目录的 ExploreID.db;已有记录的作品会跳过下载"
|
||||
),
|
||||
"settings.record_data": _("记录作品详细数据"),
|
||||
"settings.record_data_help": _(
|
||||
"将作品数据写入“保存根目录/作品文件储存文件夹/ExploreData.db”,每个作品保存一条记录"
|
||||
),
|
||||
"settings.write_mtime": _("文件修改时间"),
|
||||
"settings.write_mtime_help": _(
|
||||
"将已下载文件的文件系统修改时间设置为作品发布时间"
|
||||
),
|
||||
"settings.cookie": _("小红书网页版 Cookie"),
|
||||
"settings.cookie_placeholder": _("粘贴小红书网页版 Cookie"),
|
||||
"settings.cookie_help": _("随网页请求发送;留空时不发送 Cookie"),
|
||||
"settings.user_agent_help": _("随网页请求和文件下载请求发送的 User-Agent"),
|
||||
"settings.proxy": _("网络代理"),
|
||||
"settings.proxy_placeholder": _("不使用代理"),
|
||||
"settings.proxy_help": _(
|
||||
"网页请求和文件下载请求使用的 HTTP/HTTPS 代理;留空时直连"
|
||||
),
|
||||
"settings.timeout": _("请求超时时间"),
|
||||
"settings.second": _("秒"),
|
||||
"settings.retry": _("请求数据失败时,重试的最大次数"),
|
||||
"settings.times": _("次"),
|
||||
"settings.chunk": _("下载数据块大小"),
|
||||
"settings.byte": _("字节"),
|
||||
"settings.chunk_help": _("每次从下载响应中读取并写入临时文件的数据量"),
|
||||
"settings.language": _("程序语言"),
|
||||
"settings.chinese": _("简体中文"),
|
||||
"settings.english": _("English"),
|
||||
"settings.language_help": _(
|
||||
"使用项目现有语言文件;没有对应翻译的界面文本保留中文"
|
||||
),
|
||||
"settings.script_server": _("脚本服务器开关"),
|
||||
"settings.script_server_help": _(
|
||||
"在 0.0.0.0:5558 启动 WebSocket 服务,接收用户脚本提交的下载任务"
|
||||
),
|
||||
"settings.saved": _("配置已保存"),
|
||||
"settings.reloaded": _("下载器已重新加载"),
|
||||
"settings.discarded": _("已放弃未保存更改"),
|
||||
"about.description": _("小红书作品链接提取、作品数据采集与文件下载工具"),
|
||||
"about.version": _("当前版本"),
|
||||
"about.author": _("作者"),
|
||||
"about.license": _("开源协议"),
|
||||
"about.repository": _("项目仓库"),
|
||||
"about.open_repository": _("跳转至项目 GitHub 仓库"),
|
||||
"update.check": _("检查更新"),
|
||||
"update.checking": _("正在检查新版本,请稍等..."),
|
||||
"update.failed": _("检测新版本失败"),
|
||||
"disclaimer.content": _("免责声明\n"),
|
||||
"disclaimer.confirm": _("我已阅读并同意"),
|
||||
"disclaimer.decline": _("不同意并退出"),
|
||||
"modal.delete_history": _("删除下载记录"),
|
||||
"modal.delete_history_detail": _("仅删除当前选中的作品 ID 记录"),
|
||||
"modal.delete_history_confirm": _("确定删除选中的 {0} 条记录?"),
|
||||
"modal.delete_history_warning": _("此操作无法撤销,但不会删除已经下载的文件。"),
|
||||
"modal.cancel": _("取消"),
|
||||
"modal.confirm_delete": _("确认删除"),
|
||||
"toast.exit_monitor_first": _("请先退出监听模式"),
|
||||
"toast.monitor_switch_locked": _("监听期间无法切换到其他功能"),
|
||||
"toast.cannot_cancel": _("无法取消任务"),
|
||||
"toast.started_task": _("任务已经开始,只有待处理任务可以取消"),
|
||||
"toast.browser_unavailable": _("无法打开系统浏览器"),
|
||||
"toast.folder_unavailable": _("无法打开下载文件夹"),
|
||||
"toast.language_load_failed": _("语言文件加载失败"),
|
||||
"toast.operation_failed": _("操作失败"),
|
||||
"toast.no_supported_link": _("未识别到支持的作品链接"),
|
||||
"name.drag_disable": _("拖动调整顺序,点击停用"),
|
||||
"name.drag_enable": _("拖动或点击启用"),
|
||||
"state.processing_detail": _("正在处理作品"),
|
||||
"state.pending_detail": _("等待队列处理"),
|
||||
"state.success_detail": _("处理完成"),
|
||||
"state.failed_detail": _("处理失败"),
|
||||
"state.skipped_detail": _("作品文件下载任务未执行"),
|
||||
}
|
||||
|
||||
|
||||
def get_ui_translations(language: str) -> dict[str, str]:
|
||||
"""切换语言并生成前端使用的 GUI 语言包。"""
|
||||
|
||||
global _UI_LANGUAGE
|
||||
if language != _UI_LANGUAGE:
|
||||
switch_language(language)
|
||||
_UI_LANGUAGE = language
|
||||
messages = get_ui_messages()
|
||||
messages.update(_build_name_format_labels(language))
|
||||
return messages
|
||||
|
||||
|
||||
def _build_name_format_labels(language: str) -> dict[str, str]:
|
||||
"""生成命名格式字段的当前语言显示名称。"""
|
||||
|
||||
return {
|
||||
field: label if language == "en_US" else field
|
||||
for field, label in zip(NAME_FORMAT_FIELDS, NAME_FORMAT_ENGLISH_LABELS)
|
||||
}
|
||||
|
||||
|
||||
def normalize_name_format_field(field: str) -> str:
|
||||
"""将前端提交的中文或英文显示名称转换为后端中文字段值。"""
|
||||
|
||||
if field in NAME_FORMAT_FIELDS:
|
||||
return field
|
||||
if field in NAME_FORMAT_VALUES:
|
||||
return NAME_FORMAT_VALUES[field]
|
||||
return field
|
||||
+3
-2
@@ -3,6 +3,7 @@ from textual.app import App
|
||||
from ..application import XHS
|
||||
from ..module import (
|
||||
ROOT,
|
||||
VOLUME,
|
||||
Settings,
|
||||
)
|
||||
from .about import About
|
||||
@@ -16,8 +17,8 @@ __all__ = ["XHSDownloader"]
|
||||
|
||||
|
||||
class XHSDownloader(App):
|
||||
CSS_PATH = ROOT.parent.joinpath("static/XHS-Downloader.tcss")
|
||||
SETTINGS = Settings(ROOT)
|
||||
CSS_PATH = ROOT.joinpath("static/XHS-Downloader.tcss")
|
||||
SETTINGS = Settings(VOLUME)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
+2
-2
@@ -104,7 +104,7 @@ class Index(Screen):
|
||||
|
||||
@on(Button.Pressed, "#paste")
|
||||
def paste_button(self):
|
||||
self.query_one(Input).value = paste().replace("\n", " ")
|
||||
self.query_one(Input).value = paste()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def deal(self):
|
||||
@@ -113,7 +113,7 @@ class Index(Screen):
|
||||
await self.xhs.extract(
|
||||
self.url.value,
|
||||
True,
|
||||
data=False,
|
||||
check_record=True,
|
||||
)
|
||||
):
|
||||
self.url.value = ""
|
||||
|
||||
+5
-19
@@ -7,6 +7,7 @@ from textual.widgets import Label, LoadingIndicator
|
||||
from ..application import XHS
|
||||
from ..module import (
|
||||
RELEASES,
|
||||
compare_versions,
|
||||
)
|
||||
from ..translation import _
|
||||
|
||||
@@ -37,14 +38,15 @@ class Update(ModalScreen):
|
||||
timeout=5,
|
||||
)
|
||||
version = url.split("/")[-1]
|
||||
match self.compare_versions(
|
||||
target_major, target_minor = version.removeprefix("v").split(".")[:2]
|
||||
match compare_versions(
|
||||
f"{XHS.VERSION_MAJOR}.{XHS.VERSION_MINOR}", version, XHS.VERSION_BETA
|
||||
):
|
||||
case 4:
|
||||
args = (
|
||||
_("检测到新版本:{0}.{1}").format(
|
||||
XHS.VERSION_MAJOR,
|
||||
XHS.VERSION_MINOR,
|
||||
target_major,
|
||||
target_minor,
|
||||
),
|
||||
"warning",
|
||||
)
|
||||
@@ -74,19 +76,3 @@ class Update(ModalScreen):
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.check_update()
|
||||
|
||||
@staticmethod
|
||||
def compare_versions(
|
||||
current_version: str, target_version: str, is_development: bool
|
||||
) -> int:
|
||||
current_major, current_minor = map(int, current_version.split("."))
|
||||
target_major, target_minor = map(int, target_version.split("."))
|
||||
|
||||
if target_major > current_major:
|
||||
return 4
|
||||
if target_major == current_major:
|
||||
if target_minor > current_minor:
|
||||
return 4
|
||||
if target_minor == current_minor:
|
||||
return 3 if is_development else 1
|
||||
return 2
|
||||
|
||||
+130
-93
@@ -37,11 +37,11 @@ from ..module import (
|
||||
INFO,
|
||||
MASTER,
|
||||
REPOSITORY,
|
||||
ROOT,
|
||||
USERAGENT,
|
||||
VERSION_BETA,
|
||||
VERSION_MAJOR,
|
||||
VERSION_MINOR,
|
||||
VOLUME,
|
||||
WARNING,
|
||||
DataRecorder,
|
||||
ExtractData,
|
||||
@@ -65,6 +65,17 @@ from .video import Video
|
||||
__all__ = ["XHS"]
|
||||
|
||||
|
||||
def new_statistics(total: int = 0) -> SimpleNamespace:
|
||||
"""创建一次处理调用专用的统计对象,避免复用可变默认参数。"""
|
||||
|
||||
return SimpleNamespace(
|
||||
all=total,
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
)
|
||||
|
||||
|
||||
def data_cache(function):
|
||||
async def inner(
|
||||
self,
|
||||
@@ -154,7 +165,7 @@ class XHS:
|
||||
switch_language(language)
|
||||
self.print = Print()
|
||||
self.manager = Manager(
|
||||
ROOT,
|
||||
VOLUME,
|
||||
work_path,
|
||||
folder_name,
|
||||
name_format,
|
||||
@@ -227,6 +238,8 @@ class XHS:
|
||||
download: bool,
|
||||
index,
|
||||
count: SimpleNamespace,
|
||||
progress_callback: Callable[[dict], None] | None = None,
|
||||
task_id: str | None = None,
|
||||
):
|
||||
nickname = (
|
||||
f"{container['作者ID']}_{self.CLEANER.filter_name(container['作者昵称'])}"
|
||||
@@ -234,28 +247,27 @@ class XHS:
|
||||
filename = self.__naming_rules(container)
|
||||
path = self.downloader.generate_path(nickname, filename)
|
||||
if (u := container["下载地址"]) and download:
|
||||
if await self.skip_download(i := container["作品ID"]):
|
||||
self.logging(_("作品 {0} 存在下载记录,跳过下载").format(i))
|
||||
i = container["作品ID"]
|
||||
result = await self.downloader.run(
|
||||
u,
|
||||
container["动图地址"],
|
||||
index,
|
||||
path,
|
||||
filename,
|
||||
container["作品类型"],
|
||||
container["时间戳"],
|
||||
progress=progress_callback,
|
||||
task_id=task_id,
|
||||
)
|
||||
if not result:
|
||||
count.skip += 1
|
||||
else:
|
||||
result = await self.downloader.run(
|
||||
u,
|
||||
container["动图地址"],
|
||||
index,
|
||||
path,
|
||||
filename,
|
||||
container["作品类型"],
|
||||
container["时间戳"],
|
||||
elif all(result):
|
||||
count.success += 1
|
||||
await self.__add_record(
|
||||
i,
|
||||
)
|
||||
if not result:
|
||||
count.skip += 1
|
||||
elif all(result):
|
||||
count.success += 1
|
||||
await self.__add_record(
|
||||
i,
|
||||
)
|
||||
else:
|
||||
count.fail += 1
|
||||
else:
|
||||
count.fail += 1
|
||||
elif not u:
|
||||
self.logging(_("提取作品文件下载地址失败"), ERROR)
|
||||
count.fail += 1
|
||||
@@ -284,7 +296,10 @@ class XHS:
|
||||
url: str,
|
||||
download=False,
|
||||
index: list | tuple | None = None,
|
||||
data=True,
|
||||
check_record: bool = True,
|
||||
progress_callback: Callable[[dict], None] | None = None,
|
||||
task_id: str | None = None,
|
||||
result_callback: Callable[[dict], None] | None = None,
|
||||
) -> list[dict]:
|
||||
if not (
|
||||
urls := await self.extract_links(
|
||||
@@ -293,26 +308,33 @@ class XHS:
|
||||
):
|
||||
self.logging(_("提取小红书作品链接失败"), WARNING)
|
||||
return []
|
||||
statistics = SimpleNamespace(
|
||||
all=len(urls),
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
)
|
||||
statistics = new_statistics(len(urls))
|
||||
self.logging(_("共 {0} 个小红书作品待处理...").format(statistics.all))
|
||||
result = [
|
||||
await self.__deal_extract(
|
||||
i,
|
||||
download,
|
||||
index,
|
||||
data,
|
||||
check_record=check_record,
|
||||
count=statistics,
|
||||
progress_callback=progress_callback,
|
||||
task_id=task_id,
|
||||
)
|
||||
for i in urls
|
||||
]
|
||||
self.show_statistics(
|
||||
statistics,
|
||||
)
|
||||
if result_callback:
|
||||
result_callback(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"all": statistics.all,
|
||||
"success": statistics.success,
|
||||
"fail": statistics.fail,
|
||||
"skip": statistics.skip,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def show_statistics(
|
||||
@@ -333,7 +355,7 @@ class XHS:
|
||||
url: str,
|
||||
download=True,
|
||||
index: list | tuple = None,
|
||||
data=False,
|
||||
check_record: bool = True,
|
||||
) -> None:
|
||||
url = await self.extract_links(
|
||||
url,
|
||||
@@ -346,21 +368,16 @@ class XHS:
|
||||
url[0],
|
||||
download,
|
||||
index,
|
||||
data,
|
||||
check_record=check_record,
|
||||
)
|
||||
else:
|
||||
statistics = SimpleNamespace(
|
||||
all=len(url),
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
)
|
||||
statistics = new_statistics(len(url))
|
||||
[
|
||||
await self.__deal_extract(
|
||||
u,
|
||||
download,
|
||||
index,
|
||||
data,
|
||||
check_record=check_record,
|
||||
count=statistics,
|
||||
)
|
||||
for u in url
|
||||
@@ -372,7 +389,7 @@ class XHS:
|
||||
async def extract_links(
|
||||
self,
|
||||
url: str,
|
||||
) -> list:
|
||||
) -> list[str]:
|
||||
urls = []
|
||||
for i in url.split():
|
||||
if u := self.SHORT.search(i):
|
||||
@@ -406,21 +423,11 @@ class XHS:
|
||||
async def _get_html_data(
|
||||
self,
|
||||
url: str,
|
||||
data: bool,
|
||||
id_: str,
|
||||
count: SimpleNamespace,
|
||||
cookie: str = None,
|
||||
proxy: str = None,
|
||||
count=SimpleNamespace(
|
||||
all=0,
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
),
|
||||
) -> tuple[str, Namespace | dict]:
|
||||
if await self.skip_download(id_ := self.__extract_link_id(url)) and not data:
|
||||
msg = _("作品 {0} 存在下载记录,跳过处理").format(id_)
|
||||
self.logging(msg)
|
||||
count.skip += 1
|
||||
return id_, {"message": msg}
|
||||
) -> Namespace | dict:
|
||||
self.logging(_("开始处理作品:{0}").format(id_))
|
||||
html = await self.html.request_url(
|
||||
url,
|
||||
@@ -431,8 +438,22 @@ class XHS:
|
||||
if not namespace:
|
||||
self.logging(_("{0} 获取数据失败").format(id_), ERROR)
|
||||
count.fail += 1
|
||||
return id_, {}
|
||||
return id_, namespace
|
||||
return {}
|
||||
return namespace
|
||||
|
||||
async def _check_existing_record(
|
||||
self,
|
||||
id_: str,
|
||||
count: SimpleNamespace,
|
||||
) -> str | None:
|
||||
"""根据作品 ID 查询下载记录,存在记录时返回跳过提示。"""
|
||||
|
||||
if not await self.has_download_record(id_):
|
||||
return None
|
||||
msg = _("作品 {0} 存在下载记录,跳过处理").format(id_)
|
||||
self.logging(msg)
|
||||
count.skip += 1
|
||||
return msg
|
||||
|
||||
def _extract_data(
|
||||
self,
|
||||
@@ -455,6 +476,8 @@ class XHS:
|
||||
download: bool,
|
||||
index: list | tuple | None,
|
||||
count: SimpleNamespace,
|
||||
progress_callback: Callable[[dict], None] | None = None,
|
||||
task_id: str | None = None,
|
||||
):
|
||||
if data["作品类型"] == _("视频"):
|
||||
self.__extract_video(data, namespace)
|
||||
@@ -472,9 +495,11 @@ class XHS:
|
||||
)
|
||||
await self.__download_files(
|
||||
data,
|
||||
download,
|
||||
index,
|
||||
count,
|
||||
download=download,
|
||||
index=index,
|
||||
count=count,
|
||||
progress_callback=progress_callback,
|
||||
task_id=task_id,
|
||||
)
|
||||
# await sleep_time()
|
||||
return data
|
||||
@@ -484,22 +509,26 @@ class XHS:
|
||||
url: str,
|
||||
download: bool,
|
||||
index: list | tuple | None,
|
||||
data: bool,
|
||||
check_record: bool,
|
||||
cookie: str | None = None,
|
||||
proxy: str | None = None,
|
||||
count=SimpleNamespace(
|
||||
all=0,
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
),
|
||||
progress_callback: Callable[[dict], None] | None = None,
|
||||
task_id: str | None = None,
|
||||
count: SimpleNamespace | None = None,
|
||||
):
|
||||
id_, namespace = await self._get_html_data(
|
||||
"""提取并处理一个作品;记录只在进入流程时检查一次。"""
|
||||
|
||||
if count is None:
|
||||
count = new_statistics()
|
||||
id_ = self.extract_link_id(url)
|
||||
if check_record and (msg := await self._check_existing_record(id_, count)):
|
||||
return {"message": msg}
|
||||
namespace = await self._get_html_data(
|
||||
url,
|
||||
data,
|
||||
cookie,
|
||||
proxy,
|
||||
count,
|
||||
id_=id_,
|
||||
count=count,
|
||||
cookie=cookie,
|
||||
proxy=proxy,
|
||||
)
|
||||
if not isinstance(namespace, Namespace):
|
||||
return namespace
|
||||
@@ -518,9 +547,11 @@ class XHS:
|
||||
},
|
||||
namespace,
|
||||
id_,
|
||||
download,
|
||||
index,
|
||||
count,
|
||||
download=download,
|
||||
index=index,
|
||||
count=count,
|
||||
progress_callback=progress_callback,
|
||||
task_id=task_id,
|
||||
)
|
||||
self.logging(_("作品处理完成:{0}").format(id_))
|
||||
return data
|
||||
@@ -529,15 +560,14 @@ class XHS:
|
||||
self,
|
||||
data: dict,
|
||||
index: list | tuple | None,
|
||||
count=SimpleNamespace(
|
||||
all=0,
|
||||
success=0,
|
||||
fail=0,
|
||||
skip=0,
|
||||
),
|
||||
count: SimpleNamespace | None = None,
|
||||
):
|
||||
if count is None:
|
||||
count = new_statistics()
|
||||
namespace = self.json_to_namespace(data)
|
||||
id_ = namespace.safe_extract("noteId", "")
|
||||
if msg := await self._check_existing_record(id_, count):
|
||||
return {"message": msg}
|
||||
if not (
|
||||
data := self._extract_data(
|
||||
namespace,
|
||||
@@ -550,9 +580,9 @@ class XHS:
|
||||
data,
|
||||
namespace,
|
||||
id_,
|
||||
True,
|
||||
index,
|
||||
count,
|
||||
download=True,
|
||||
index=index,
|
||||
count=count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -575,9 +605,11 @@ class XHS:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __extract_link_id(url: str) -> str:
|
||||
def extract_link_id(url: str) -> str:
|
||||
"""从已提取的作品链接中获取作品 ID。"""
|
||||
|
||||
link = urlparse(url)
|
||||
return link.path.split("/")[-1]
|
||||
return link.path.rstrip("/").split("/")[-1]
|
||||
|
||||
def __generate_data_object(self, html: str) -> Namespace:
|
||||
data = self.convert.run(html)
|
||||
@@ -624,7 +656,7 @@ class XHS:
|
||||
self,
|
||||
delay=1,
|
||||
download=True,
|
||||
data=False,
|
||||
check_record: bool = True,
|
||||
) -> None:
|
||||
self.logging(
|
||||
_(
|
||||
@@ -636,12 +668,17 @@ class XHS:
|
||||
copy("")
|
||||
await gather(
|
||||
self.__get_link(delay),
|
||||
self.__receive_link(delay, download=download, index=None, data=data),
|
||||
self.__receive_link(
|
||||
delay,
|
||||
download=download,
|
||||
index=None,
|
||||
check_record=check_record,
|
||||
),
|
||||
)
|
||||
|
||||
async def __get_link(self, delay: int):
|
||||
while not self.event.is_set():
|
||||
if (t := paste().replace("\n", " ")).lower() == "close":
|
||||
if (t := paste()).lower() == "close":
|
||||
self.stop_monitor()
|
||||
elif t != self.clipboard_cache:
|
||||
self.clipboard_cache = t
|
||||
@@ -670,7 +707,7 @@ class XHS:
|
||||
def stop_monitor(self):
|
||||
self.event.set()
|
||||
|
||||
async def skip_download(self, id_: str) -> bool:
|
||||
async def has_download_record(self, id_: str) -> bool:
|
||||
return bool(await self.id_recorder.select(id_))
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -748,7 +785,7 @@ class XHS:
|
||||
- **index**: 下载指定序号的图片文件,仅对图文作品生效;download 参数设置为 false 时不生效;可选参数
|
||||
- **cookie**: 请求数据时使用的 Cookie;可选参数
|
||||
- **proxy**: 请求数据时使用的代理;可选参数
|
||||
- **skip**: 是否跳过存在下载记录的作品;设置为 true 将不会返回存在下载记录的作品数据;可选参数
|
||||
- **check_record**: 是否跳过已有下载记录的作品;可选参数
|
||||
""")
|
||||
),
|
||||
tags=["API"],
|
||||
@@ -766,9 +803,9 @@ class XHS:
|
||||
url[0],
|
||||
extract.download,
|
||||
extract.index,
|
||||
not extract.skip,
|
||||
extract.cookie,
|
||||
extract.proxy,
|
||||
check_record=extract.check_record,
|
||||
cookie=extract.cookie,
|
||||
proxy=extract.proxy,
|
||||
):
|
||||
msg = _("获取小红书作品数据成功")
|
||||
else:
|
||||
@@ -952,7 +989,7 @@ class XHS:
|
||||
url[0],
|
||||
download,
|
||||
index,
|
||||
True,
|
||||
check_record=True,
|
||||
):
|
||||
msg = _("获取小红书作品数据成功")
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from asyncio import Semaphore, gather
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from aiofiles import open
|
||||
from httpx import HTTPError
|
||||
@@ -77,7 +77,9 @@ class Download:
|
||||
filename: str,
|
||||
type_: str,
|
||||
mtime: int,
|
||||
) -> tuple[Path, list[Any]]:
|
||||
progress: Callable[[dict], None] | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> list[Any]:
|
||||
if type_ == _("视频"):
|
||||
tasks = self.__ready_download_video(
|
||||
urls,
|
||||
@@ -104,6 +106,8 @@ class Download:
|
||||
name,
|
||||
format_,
|
||||
mtime,
|
||||
progress,
|
||||
task_id,
|
||||
)
|
||||
for url, name, format_ in tasks
|
||||
]
|
||||
@@ -200,14 +204,29 @@ class Download:
|
||||
name: str,
|
||||
format_: str,
|
||||
mtime: int,
|
||||
progress: Callable[[dict], None] | None,
|
||||
task_id: str | None,
|
||||
):
|
||||
async with self.SEMAPHORE:
|
||||
headers = self.headers.copy()
|
||||
temp = self.temp.joinpath(f"{name}.{format_}")
|
||||
self.__update_headers_range(
|
||||
completed = self.__update_headers_range(
|
||||
headers,
|
||||
temp,
|
||||
)
|
||||
|
||||
def report(state: str, total: int | None = None) -> None:
|
||||
if progress:
|
||||
progress(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"filename": f"{name}.{format_}",
|
||||
"completed_bytes": completed,
|
||||
"total_bytes": total,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
async with self.client.stream(
|
||||
"GET",
|
||||
@@ -226,10 +245,14 @@ class Download:
|
||||
# response.headers.get(
|
||||
# 'content-length', 0)) or None,
|
||||
# )
|
||||
content_length = int(response.headers.get("content-length", 0) or 0)
|
||||
total = completed + content_length if content_length else None
|
||||
report("downloading", total)
|
||||
async with open(temp, "ab") as f:
|
||||
async for chunk in response.aiter_bytes(self.chunk):
|
||||
await f.write(chunk)
|
||||
# self.__update_progress(bar, len(chunk))
|
||||
completed += len(chunk)
|
||||
report("downloading", total)
|
||||
real = await self.__suffix_with_file(
|
||||
temp,
|
||||
path,
|
||||
@@ -243,11 +266,11 @@ class Download:
|
||||
mtime,
|
||||
self.write_mtime,
|
||||
)
|
||||
# self.__create_progress(bar, None)
|
||||
report("completed", total)
|
||||
logging(self.print, _("文件 {0} 下载成功").format(real.name))
|
||||
return True
|
||||
except HTTPError as error:
|
||||
# self.__create_progress(bar, None)
|
||||
report("failed")
|
||||
logging(
|
||||
self.print,
|
||||
_("网络异常,{0} 下载失败,错误信息: {1}").format(
|
||||
@@ -257,6 +280,7 @@ class Download:
|
||||
)
|
||||
return False
|
||||
except CacheError as error:
|
||||
report("failed")
|
||||
self.manager.delete(temp)
|
||||
logging(
|
||||
self.print,
|
||||
|
||||
@@ -14,6 +14,7 @@ from .static import (
|
||||
VERSION_MINOR,
|
||||
VERSION_BETA,
|
||||
ROOT,
|
||||
VOLUME,
|
||||
REPOSITORY,
|
||||
LICENCE,
|
||||
RELEASES,
|
||||
@@ -34,6 +35,7 @@ from .static import (
|
||||
__VERSION__,
|
||||
)
|
||||
from .tools import (
|
||||
compare_versions,
|
||||
retry,
|
||||
logging,
|
||||
sleep_time,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from http.cookies import SimpleCookie
|
||||
from os import utime
|
||||
from pathlib import Path
|
||||
from re import compile, sub
|
||||
from shutil import move, rmtree
|
||||
from os import utime
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
@@ -17,7 +19,6 @@ from source.expansion import remove_empty_directories
|
||||
from ..translation import _
|
||||
from .static import HEADERS, USERAGENT, WARNING
|
||||
from .tools import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..expansion import Cleaner
|
||||
@@ -87,8 +88,8 @@ class Manager:
|
||||
self.blank_headers = HEADERS | {
|
||||
"user-agent": user_agent or USERAGENT,
|
||||
}
|
||||
self.retry = retry
|
||||
self.chunk = chunk
|
||||
self.retry = self.__check_integer(retry, 5, 0)
|
||||
self.chunk = self.__check_integer(chunk, 2 * 1024 * 1024, 1024 * 1024)
|
||||
self.name_format = self.__check_name_format(name_format)
|
||||
self.record_data = self.check_bool(record_data, False)
|
||||
self.image_format = self.__check_image_format(image_format)
|
||||
@@ -97,7 +98,7 @@ class Manager:
|
||||
self.proxy_tip = None
|
||||
self.proxy = self.__check_proxy(proxy)
|
||||
self.print_proxy_tip()
|
||||
self.timeout = timeout
|
||||
self.timeout = self.__check_integer(timeout, 10, 1)
|
||||
self.request_client = AsyncClient(
|
||||
headers=self.blank_headers
|
||||
| {
|
||||
@@ -204,6 +205,15 @@ class Manager:
|
||||
def check_bool(value: bool, default: bool) -> bool:
|
||||
return value if isinstance(value, bool) else default
|
||||
|
||||
@staticmethod
|
||||
def __check_integer(value: int, default: int, minimum: int) -> int:
|
||||
"""将配置中的整数参数限制在下载器可接受的范围内。"""
|
||||
|
||||
try:
|
||||
return max(minimum, value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def close(self):
|
||||
await self.request_client.aclose()
|
||||
await self.download_client.aclose()
|
||||
|
||||
@@ -7,7 +7,7 @@ class ExtractParams(BaseModel):
|
||||
index: list[str | int] | None = None
|
||||
cookie: str = None
|
||||
proxy: str = None
|
||||
skip: bool = False
|
||||
check_record: bool = True
|
||||
|
||||
|
||||
class ExtractData(BaseModel):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from asyncio import CancelledError
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
from shutil import move
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiosqlite import connect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -55,7 +56,49 @@ class IDRecorder:
|
||||
async def all(self):
|
||||
if self.switch:
|
||||
await self.cursor.execute("SELECT ID FROM explore_id")
|
||||
return [i[0] for i in await self.cursor.fetchmany()]
|
||||
return [i[0] for i in await self.cursor.fetchall()]
|
||||
|
||||
async def count(self, query: str = "") -> int:
|
||||
if not self.switch:
|
||||
return 0
|
||||
statement = "SELECT COUNT(*) FROM explore_id"
|
||||
parameters = ()
|
||||
if query:
|
||||
statement += " WHERE INSTR(LOWER(ID), LOWER(?)) > 0"
|
||||
parameters = (query,)
|
||||
async with self.database.execute(statement, parameters) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
async def page(
|
||||
self,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[str]:
|
||||
if not self.switch:
|
||||
return []
|
||||
limit = max(1, min(int(limit), 100))
|
||||
offset = max(0, int(offset))
|
||||
statement = "SELECT ID FROM explore_id"
|
||||
parameters: tuple = ()
|
||||
if query:
|
||||
statement += " WHERE INSTR(LOWER(ID), LOWER(?)) > 0"
|
||||
parameters = (query,)
|
||||
statement += " ORDER BY rowid DESC LIMIT ? OFFSET ?"
|
||||
async with self.database.execute(
|
||||
statement,
|
||||
(*parameters, limit, offset),
|
||||
) as cursor:
|
||||
return [row[0] for row in await cursor.fetchall()]
|
||||
|
||||
async def clear(self) -> int:
|
||||
if not self.switch:
|
||||
return 0
|
||||
count = await self.count()
|
||||
await self.database.execute("DELETE FROM explore_id")
|
||||
await self.database.commit()
|
||||
return count
|
||||
|
||||
async def __aenter__(self):
|
||||
self.compatible()
|
||||
@@ -188,4 +231,4 @@ class MapRecorder(IDRecorder):
|
||||
async def all(self):
|
||||
if self.switch:
|
||||
await self.cursor.execute("SELECT ID, NAME FROM mapping_data")
|
||||
return [i[0] for i in await self.cursor.fetchmany()]
|
||||
return await self.cursor.fetchall()
|
||||
|
||||
@@ -2,7 +2,8 @@ from json import dump, load
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
from shutil import move
|
||||
from .static import ROOT, USERAGENT
|
||||
|
||||
from .static import USERAGENT, VOLUME
|
||||
|
||||
__all__ = ["Settings"]
|
||||
|
||||
@@ -35,15 +36,16 @@ class Settings:
|
||||
"language": "zh_CN", # 语言设置
|
||||
"script_server": False, # 是否启用脚本服务器
|
||||
"note_format": "", # 作品信息格式(txt/md/all)
|
||||
"disclaimer_accepted": False, # 是否已同意免责声明
|
||||
}
|
||||
# 根据操作系统设置编码格式
|
||||
encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8"
|
||||
|
||||
def __init__(self, root: Path = ROOT):
|
||||
def __init__(self, root: Path = VOLUME):
|
||||
"""初始化Settings类
|
||||
|
||||
Args:
|
||||
root: 设置文件的根目录路径,默认为ROOT
|
||||
root: 设置文件的根目录路径,默认为 VOLUME
|
||||
"""
|
||||
# 设置文件路径
|
||||
self.name = "settings.json"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_MAJOR = 2
|
||||
VERSION_MINOR = 8
|
||||
VERSION_BETA = True
|
||||
__VERSION__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{'beta' if VERSION_BETA else 'stable'}"
|
||||
ROOT = (
|
||||
Path(sys.executable).resolve().parent.joinpath("Volume")
|
||||
ROOT: Path = (
|
||||
Path(sys.executable).resolve().parent
|
||||
if getattr(sys, "frozen", False)
|
||||
else Path(__file__).resolve().parent.parent.parent.joinpath("Volume")
|
||||
else Path(__file__).resolve().parent.parent.parent
|
||||
)
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
VOLUME: Path = ROOT / "Volume"
|
||||
VOLUME.mkdir(exist_ok=True)
|
||||
PROJECT = f"XHS-Downloader V{VERSION_MAJOR}.{VERSION_MINOR} {
|
||||
'Beta' if VERSION_BETA else 'Stable'
|
||||
}"
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
from asyncio import sleep
|
||||
from random import lognormvariate
|
||||
from math import log
|
||||
from random import lognormvariate
|
||||
from typing import Callable
|
||||
|
||||
from rich import print
|
||||
@@ -51,6 +51,22 @@ def logging(log: Callable, text, style=INFO):
|
||||
)
|
||||
|
||||
|
||||
def compare_versions(
|
||||
current_version: str,
|
||||
target_version: str,
|
||||
is_development: bool,
|
||||
) -> int:
|
||||
"""比较两个主版本号,返回正式版更新状态码。"""
|
||||
|
||||
current = tuple(map(int, current_version.removeprefix("v").split(".")[:2]))
|
||||
target = tuple(map(int, target_version.removeprefix("v").split(".")[:2]))
|
||||
if target > current:
|
||||
return 4
|
||||
if target == current:
|
||||
return 3 if is_development else 1
|
||||
return 2
|
||||
|
||||
|
||||
def get_wait_time(
|
||||
avg_delay: float | int = 6.0,
|
||||
sigma: float = 0.6,
|
||||
|
||||
+1170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,636 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>XHS-Downloader</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg class="icon-sprite">
|
||||
<symbol id="plus" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14M12 5v14"/>
|
||||
</symbol>
|
||||
<symbol id="list" viewBox="0 0 24 24">
|
||||
<path d="M8 6h13M8 12h13M8 18h13"/>
|
||||
<path d="M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</symbol>
|
||||
<symbol id="history" viewBox="0 0 24 24">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/>
|
||||
<path d="M3 3v5h5M12 7v5l3 2"/>
|
||||
</symbol>
|
||||
<symbol id="terminal" viewBox="0 0 24 24">
|
||||
<path d="m4 17 6-6-6-6M12 19h8"/>
|
||||
</symbol>
|
||||
<symbol id="sliders" viewBox="0 0 24 24">
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>
|
||||
</symbol>
|
||||
<symbol id="clipboard" viewBox="0 0 24 24">
|
||||
<rect width="14" height="16" x="5" y="4" rx="2"/>
|
||||
<path d="M9 4V2h6v2M9 9h6M9 13h6"/>
|
||||
</symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
</symbol>
|
||||
<symbol id="link" viewBox="0 0 24 24">
|
||||
<path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/>
|
||||
</symbol>
|
||||
<symbol id="trash" viewBox="0 0 24 24">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 15H6L5 6M10 11v6M14 11v6"/>
|
||||
</symbol>
|
||||
<symbol id="play" viewBox="0 0 24 24">
|
||||
<path d="m6 3 14 9-14 9Z"/>
|
||||
</symbol>
|
||||
<symbol id="x" viewBox="0 0 24 24">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24">
|
||||
<path d="m5 12 4 4L19 6"/>
|
||||
</symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<path d="m20 20-4-4"/>
|
||||
</symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24">
|
||||
<path d="M20 7h-5V2M4 17h5v5"/>
|
||||
<path d="M5.1 9a8 8 0 0 1 13.3-3L20 7M4 17l1.6 1A8 8 0 0 0 19 15"/>
|
||||
</symbol>
|
||||
<symbol id="external" viewBox="0 0 24 24">
|
||||
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
</symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M12 11v5M12 8h.01"/>
|
||||
</symbol>
|
||||
<symbol id="menu" viewBox="0 0 24 24">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</symbol>
|
||||
<symbol id="chevron" viewBox="0 0 24 24">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<div class="startup-screen" id="startupScreen">
|
||||
<div class="startup-content">
|
||||
<span class="startup-spinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<img src="../XHS-Downloader.png" alt="" class="brand-mark">
|
||||
<div class="brand-copy">
|
||||
<strong>XHS-Downloader</strong>
|
||||
</div>
|
||||
<button class="icon-button sidebar-close" id="sidebarClose" type="button">
|
||||
<svg>
|
||||
<use href="#x"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="primary-nav">
|
||||
<button class="nav-item active" type="button" data-view="new-task">
|
||||
<svg>
|
||||
<use href="#plus"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.new_task"></span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="queue">
|
||||
<svg>
|
||||
<use href="#list"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.queue"></span><span class="nav-badge" id="queueBadge">0</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="monitor">
|
||||
<svg>
|
||||
<use href="#clipboard"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.monitor"></span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="history">
|
||||
<svg>
|
||||
<use href="#history"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.history"></span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="logs">
|
||||
<svg>
|
||||
<use href="#terminal"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.logs"></span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="settings">
|
||||
<svg>
|
||||
<use href="#sliders"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.settings"></span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" data-view="about">
|
||||
<svg>
|
||||
<use href="#info"/>
|
||||
</svg>
|
||||
<span data-i18n="nav.about"></span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="sidebar-scrim" id="sidebarScrim"></div>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">
|
||||
<button class="icon-button mobile-menu" id="mobileMenu" type="button">
|
||||
<svg>
|
||||
<use href="#menu"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 id="viewTitle"></h1>
|
||||
<p id="viewMeta"></p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="mainContent">
|
||||
<section class="view active" data-page="new-task">
|
||||
<section class="surface composer">
|
||||
<div class="composer-head">
|
||||
<span class="composer-hint"><svg><use href="#link"/></svg><span
|
||||
data-i18n="task.link"></span></span>
|
||||
</div>
|
||||
<textarea id="urlInput" rows="5" spellcheck="false"
|
||||
data-i18n-placeholder="task.link_placeholder"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-actions">
|
||||
<button class="secondary-button" id="pasteButton" type="button">
|
||||
<svg>
|
||||
<use href="#clipboard"/>
|
||||
</svg>
|
||||
<span data-i18n="task.paste"></span></button>
|
||||
<button class="secondary-button" id="pasteAndProcess" type="button">
|
||||
<svg>
|
||||
<use href="#play"/>
|
||||
</svg>
|
||||
<span data-i18n="task.paste_process"></span></button>
|
||||
<button class="secondary-button" id="clearInput" type="button">
|
||||
<svg>
|
||||
<use href="#x"/>
|
||||
</svg>
|
||||
<span data-i18n="task.clear"></span></button>
|
||||
<button class="primary-button" id="createTask" type="button" disabled>
|
||||
<svg>
|
||||
<use href="#play"/>
|
||||
</svg>
|
||||
<span data-i18n="task.start"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="activity-section">
|
||||
<div class="section-heading">
|
||||
<div><h3 data-i18n="nav.queue"></h3><span id="dashboardTaskCount"></span></div>
|
||||
<button class="text-button" type="button" data-go="queue"><span
|
||||
data-i18n="task.view_queue"></span>
|
||||
<svg>
|
||||
<use href="#chevron"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="surface compact-task-list" id="dashboardTasks">
|
||||
<div class="dashboard-empty" data-i18n="task.queue_empty"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view monitor-view" data-page="monitor">
|
||||
<div class="metric-strip monitor-metrics">
|
||||
<div><span data-i18n="monitor.status"></span><strong class="monitoring-value inactive"
|
||||
id="monitorState"><i></i><span
|
||||
data-i18n="monitor.inactive"></span></strong></div>
|
||||
<div><span data-i18n="monitor.detected"></span><strong id="monitorDetected"></strong></div>
|
||||
<div><span data-i18n="monitor.created"></span><strong id="monitorCreated"></strong></div>
|
||||
<div class="monitor-action">
|
||||
<button class="primary-button" id="monitorToggleButton" type="button">
|
||||
<svg>
|
||||
<use href="#play"/>
|
||||
</svg>
|
||||
<span data-i18n="monitor.start"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="processing-layout monitor-processing-layout">
|
||||
<section class="file-download-section monitor-file-download">
|
||||
<div class="section-heading">
|
||||
<div><h3 data-i18n="download.files"></h3><span></span></div>
|
||||
</div>
|
||||
<div class="surface file-download-list" id="monitorFileDownloadList">
|
||||
<div class="file-download-empty">
|
||||
<svg>
|
||||
<use href="#download"/>
|
||||
</svg>
|
||||
<span data-i18n="download.empty"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="queue-panel monitor-panel">
|
||||
<div class="queue-toolbar monitor-queue-toolbar">
|
||||
<h3 data-i18n="monitor.queue"></h3>
|
||||
<div class="segmented" id="monitorQueueFilters">
|
||||
<button class="active" type="button" data-filter="all" data-i18n="queue.all"></button>
|
||||
<button type="button" data-filter="processing" data-i18n="queue.processing"></button>
|
||||
<button type="button" data-filter="pending" data-i18n="queue.pending"></button>
|
||||
<button type="button" data-filter="success" data-i18n="queue.success"></button>
|
||||
<button type="button" data-filter="failed" data-i18n="queue.failed"></button>
|
||||
<button type="button" data-filter="skipped" data-i18n="queue.skipped"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface queue-results">
|
||||
<div class="queue-list monitor-queue-list" id="monitorEvents" hidden></div>
|
||||
<div class="empty-state monitor-queue-empty" id="monitorQueueEmpty"><span><svg><use
|
||||
href="#list"/></svg></span><strong data-i18n="monitor.queue_empty"></strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="queue">
|
||||
<div class="page-heading queue-heading">
|
||||
<div class="heading-actions">
|
||||
<button class="secondary-button" id="openDownloadFolder" type="button">
|
||||
<svg>
|
||||
<use href="#external"/>
|
||||
</svg>
|
||||
<span data-i18n="queue.open_folder"></span></button>
|
||||
<button class="secondary-button danger-subtle" id="clearFinished" type="button">
|
||||
<svg>
|
||||
<use href="#trash"/>
|
||||
</svg>
|
||||
<span data-i18n="queue.clear_finished"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-strip">
|
||||
<div><span data-i18n="queue.pending"></span><strong id="pendingMetric"></strong></div>
|
||||
<div><span data-i18n="queue.success"></span><strong id="successMetric"></strong></div>
|
||||
<div><span data-i18n="queue.failed"></span><strong id="failedMetric"></strong></div>
|
||||
<div><span data-i18n="queue.skipped"></span><strong id="skippedMetric"></strong></div>
|
||||
</div>
|
||||
<div class="processing-layout queue-processing-layout">
|
||||
<section class="file-download-section">
|
||||
<div class="section-heading">
|
||||
<div><h3 data-i18n="download.files"></h3><span></span></div>
|
||||
</div>
|
||||
<div class="surface file-download-list" id="fileDownloadList">
|
||||
<div class="file-download-empty">
|
||||
<svg>
|
||||
<use href="#download"/>
|
||||
</svg>
|
||||
<span data-i18n="download.empty"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="queue-panel">
|
||||
<div class="queue-toolbar">
|
||||
<h3 data-i18n="nav.queue"></h3>
|
||||
<div class="segmented" id="queueFilters">
|
||||
<button class="active" type="button" data-filter="all" data-i18n="queue.all"></button>
|
||||
<button type="button" data-filter="processing" data-i18n="queue.processing"></button>
|
||||
<button type="button" data-filter="pending" data-i18n="queue.pending"></button>
|
||||
<button type="button" data-filter="success" data-i18n="queue.success"></button>
|
||||
<button type="button" data-filter="failed" data-i18n="queue.failed"></button>
|
||||
<button type="button" data-filter="skipped" data-i18n="queue.skipped"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface queue-results">
|
||||
<div class="queue-list" id="queueList" hidden></div>
|
||||
<div class="empty-state" id="queueEmpty"><span><svg><use href="#list"/></svg></span><strong
|
||||
data-i18n="task.queue_empty"></strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view history-view" data-page="history">
|
||||
<div class="page-heading">
|
||||
<div><h2 data-i18n="nav.history"></h2>
|
||||
<p id="recordSummary"></p></div>
|
||||
<button class="secondary-button danger-subtle" id="deleteSelectedRecords" type="button" disabled>
|
||||
<span data-i18n="history.delete_selected"></span></button>
|
||||
</div>
|
||||
<div class="history-tools">
|
||||
<div class="search-box">
|
||||
<svg>
|
||||
<use href="#search"/>
|
||||
</svg>
|
||||
<input id="historySearch" type="search"
|
||||
data-i18n-placeholder="history.search_placeholder">
|
||||
</div>
|
||||
<div class="record-selection-tools">
|
||||
<label><input id="selectAllRecords" type="checkbox"><span class="checkmark"><svg><use
|
||||
href="#check"/></svg></span><span data-i18n="history.select_page"></span></label>
|
||||
<button class="icon-text-button" id="invertRecordSelection" type="button">
|
||||
<svg>
|
||||
<use href="#refresh"/>
|
||||
</svg>
|
||||
<span data-i18n="history.invert_page"></span></button>
|
||||
<span id="selectedRecordCount"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface history-list-wrap">
|
||||
<ul class="history-list" id="historyBody" hidden></ul>
|
||||
<div class="table-empty" id="historyEmpty">
|
||||
<svg>
|
||||
<use href="#search"/>
|
||||
</svg>
|
||||
<strong data-i18n="history.empty"></strong></div>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="recordRange"></span>
|
||||
<div>
|
||||
<button class="icon-button" id="historyPrev" type="button"
|
||||
data-i18n-title="history.previous" disabled>
|
||||
<svg class="flip">
|
||||
<use href="#chevron"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="pagination-page" id="historyPage">0 / 0</span>
|
||||
<button class="icon-button" id="historyNext" type="button" data-i18n-title="history.next"
|
||||
disabled>
|
||||
<svg>
|
||||
<use href="#chevron"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-page="logs">
|
||||
<div class="log-console" id="logConsole"></div>
|
||||
</section>
|
||||
|
||||
<section class="view settings-view" data-page="settings">
|
||||
<div class="page-heading settings-heading">
|
||||
<div><h2 data-i18n="nav.settings"></h2></div>
|
||||
<div class="heading-actions">
|
||||
<button class="secondary-button" id="discardSettings" type="button">
|
||||
<svg>
|
||||
<use href="#x"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.discard"></span></button>
|
||||
<button class="primary-button" id="saveSettings" type="button">
|
||||
<svg>
|
||||
<use href="#check"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.save"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav">
|
||||
<button class="active" type="button" data-setting="download-settings"
|
||||
data-i18n="settings.download"></button>
|
||||
<button type="button" data-setting="format-settings" data-i18n="settings.format"></button>
|
||||
<button type="button" data-setting="archive-settings" data-i18n="settings.archive"></button>
|
||||
<button type="button" data-setting="network-settings" data-i18n="settings.network"></button>
|
||||
<button type="button" data-setting="general-settings" data-i18n="settings.general"></button>
|
||||
</nav>
|
||||
<div class="surface settings-panel" id="settingsPanel">
|
||||
<section class="setting-group" id="download-settings">
|
||||
<div class="group-heading"><h3 data-i18n="settings.download"></h3></div>
|
||||
<div class="field-grid">
|
||||
<label class="field full"><span data-i18n="settings.work_path"></span>
|
||||
<div class="input-action"><input id="settingsWorkPath" type="text">
|
||||
<button id="browseWorkPath" type="button" data-i18n="settings.browse"></button>
|
||||
</div>
|
||||
<small data-i18n="settings.work_path_help"></small></label>
|
||||
<label class="field full"><span data-i18n="settings.folder_name"></span><input
|
||||
id="settingsFolderName" type="text"><small
|
||||
data-i18n="settings.folder_name_help"></small></label>
|
||||
<div class="field full name-format-field">
|
||||
<span data-i18n="settings.name_format"></span>
|
||||
<small data-i18n="settings.name_format_help"></small>
|
||||
<div class="name-format-editor">
|
||||
<section class="name-format-group">
|
||||
<header><strong data-i18n="settings.enabled_fields"></strong><span
|
||||
id="enabledNameFieldCount">0</span></header>
|
||||
<div class="name-format-zone enabled" id="enabledNameFields"
|
||||
data-name-state="enabled"></div>
|
||||
</section>
|
||||
<section class="name-format-group">
|
||||
<header><strong data-i18n="settings.disabled_fields"></strong><span
|
||||
id="disabledNameFieldCount">0</span></header>
|
||||
<div class="name-format-zone" id="disabledNameFields"
|
||||
data-name-state="disabled"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="setting-group" id="format-settings">
|
||||
<div class="group-heading"><h3 data-i18n="settings.format"></h3></div>
|
||||
<div class="setting-rows">
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.image_download"></strong><span
|
||||
data-i18n="settings.image_download_help"></span></div>
|
||||
<label class="toggle"><input id="settingsImageDownload" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.video_download"></strong><span
|
||||
data-i18n="settings.video_download_help"></span></div>
|
||||
<label class="toggle"><input id="settingsVideoDownload" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.live_download"></strong><span
|
||||
data-i18n="settings.live_download_help"></span></div>
|
||||
<label class="toggle"><input id="settingsLiveDownload" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
</div>
|
||||
<div class="field-grid select-grid">
|
||||
<label class="field"><span data-i18n="settings.image_format"></span><select
|
||||
id="settingsImageFormat">
|
||||
<option value="auto">AUTO</option>
|
||||
<option value="png">PNG</option>
|
||||
<option value="webp">WEBP</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="heic">HEIC</option>
|
||||
</select><small data-i18n="settings.image_format_help"></small></label>
|
||||
<label class="field"><span data-i18n="settings.video_preference"></span><select
|
||||
id="settingsVideoPreference">
|
||||
<option value="resolution"
|
||||
data-i18n="settings.video_preference_resolution"></option>
|
||||
<option value="bitrate" data-i18n="settings.video_preference_bitrate"></option>
|
||||
<option value="size" data-i18n="settings.video_preference_size"></option>
|
||||
</select><small data-i18n="settings.video_preference_help"></small></label>
|
||||
<label class="field"><span data-i18n="settings.note_format"></span><select
|
||||
id="settingsNoteFormat">
|
||||
<option value="" data-i18n="settings.note_format_none"></option>
|
||||
<option value="txt">TXT</option>
|
||||
<option value="md">Markdown</option>
|
||||
<option value="all" data-i18n="settings.note_format_all"></option>
|
||||
</select><small data-i18n="settings.note_format_help"></small></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="setting-group" id="archive-settings">
|
||||
<div class="group-heading"><h3 data-i18n="settings.archive"></h3></div>
|
||||
<div class="setting-rows">
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.folder_mode"></strong><span
|
||||
data-i18n="settings.folder_mode_help"></span></div>
|
||||
<label class="toggle"><input id="settingsFolderMode" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.author_archive"></strong><span
|
||||
data-i18n="settings.author_archive_help"></span></div>
|
||||
<label class="toggle"><input id="settingsAuthorArchive" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.download_record"></strong><span
|
||||
data-i18n="settings.download_record_help"></span></div>
|
||||
<label class="toggle"><input id="settingsDownloadRecord" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.record_data"></strong><span
|
||||
data-i18n="settings.record_data_help"></span></div>
|
||||
<label class="toggle"><input id="settingsRecordData" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.write_mtime"></strong><span
|
||||
data-i18n="settings.write_mtime_help"></span></div>
|
||||
<label class="toggle"><input id="settingsWriteMtime" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="setting-group" id="network-settings">
|
||||
<div class="group-heading"><h3 data-i18n="settings.network"></h3></div>
|
||||
<div class="field-grid">
|
||||
<label class="field full"><span data-i18n="settings.cookie"></span><textarea
|
||||
id="settingsCookie" rows="4" spellcheck="false" autocomplete="off"
|
||||
data-i18n-placeholder="settings.cookie_placeholder"></textarea><small
|
||||
data-i18n="settings.cookie_help"></small></label>
|
||||
<label class="field full"><span>User-Agent</span><input id="settingsUserAgent"
|
||||
type="text"><small
|
||||
data-i18n="settings.user_agent_help"></small></label>
|
||||
<label class="field"><span data-i18n="settings.proxy"></span><input id="settingsProxy"
|
||||
type="text"
|
||||
data-i18n-placeholder="settings.proxy_placeholder"><small
|
||||
data-i18n="settings.proxy_help"></small></label>
|
||||
<label class="field"><span data-i18n="settings.timeout"></span>
|
||||
<div class="suffix-input"><input id="settingsTimeout" type="number" min="1"><span
|
||||
data-i18n="settings.second"></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><span data-i18n="settings.retry"></span>
|
||||
<div class="suffix-input"><input id="settingsMaxRetry" type="number" min="0"><span
|
||||
data-i18n="settings.times"></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><span data-i18n="settings.chunk"></span>
|
||||
<div class="suffix-input wide-suffix"><input id="settingsChunkSize" type="number"
|
||||
min="1" step="1"><span
|
||||
data-i18n="settings.byte"></span></div>
|
||||
<small data-i18n="settings.chunk_help"></small></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="setting-group" id="general-settings">
|
||||
<div class="group-heading"><h3 data-i18n="settings.general"></h3></div>
|
||||
<div class="field-grid select-grid general-grid">
|
||||
<label class="field"><span data-i18n="settings.language"></span><select
|
||||
id="settingsLanguage">
|
||||
<option value="zh_CN" data-i18n="settings.chinese"></option>
|
||||
<option value="en_US" data-i18n="settings.english"></option>
|
||||
</select><small data-i18n="settings.language_help"></small></label>
|
||||
</div>
|
||||
<div class="setting-rows standalone">
|
||||
<div class="setting-row">
|
||||
<div><strong data-i18n="settings.script_server"></strong><span
|
||||
data-i18n="settings.script_server_help"></span></div>
|
||||
<label class="toggle"><input id="settingsScriptServer" type="checkbox"><span
|
||||
class="switch"></span></label></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view about-view" data-page="about">
|
||||
<section class="surface about-panel">
|
||||
<div class="about-identity">
|
||||
<img src="../XHS-Downloader.png" alt="XHS-Downloader">
|
||||
<div class="about-copy">
|
||||
<h2>XHS-Downloader</h2>
|
||||
<p data-i18n="about.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-details">
|
||||
<div><span data-i18n="about.version"></span><strong id="aboutVersion">-</strong></div>
|
||||
<div><span data-i18n="about.author"></span><strong id="aboutAuthor">-</strong></div>
|
||||
<div><span data-i18n="about.license"></span><strong id="aboutLicense">-</strong></div>
|
||||
<div><span data-i18n="about.repository"></span><strong id="aboutRepository">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-actions">
|
||||
<div class="about-update-result" id="aboutUpdateResult" hidden>
|
||||
<svg>
|
||||
<use href="#info"/>
|
||||
</svg>
|
||||
<span></span>
|
||||
</div>
|
||||
<button class="secondary-button" id="openRepository" type="button">
|
||||
<svg>
|
||||
<use href="#external"/>
|
||||
</svg>
|
||||
<span data-i18n="about.open_repository"></span></button>
|
||||
<button class="primary-button" id="pageCheckUpdate" type="button">
|
||||
<svg>
|
||||
<use href="#refresh"/>
|
||||
</svg>
|
||||
<span data-i18n="update.check"></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="toast-region" id="toastRegion"></div>
|
||||
|
||||
<dialog class="modal disclaimer-modal" id="disclaimerModal" data-blocking="true">
|
||||
<div class="modal-body">
|
||||
<pre class="disclaimer-text" data-i18n="disclaimer.content"></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="primary-button" type="button" id="acceptDisclaimer">
|
||||
<svg>
|
||||
<use href="#check"/>
|
||||
</svg>
|
||||
<span data-i18n="disclaimer.confirm"></span></button>
|
||||
<button class="secondary-button" type="button" id="declineDisclaimer"
|
||||
data-i18n="disclaimer.decline"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog class="modal compact-modal" id="recordModal">
|
||||
<div class="modal-head">
|
||||
<div><strong data-i18n="modal.delete_history"></strong><span data-i18n="modal.delete_history_detail"></span>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-close-modal>
|
||||
<svg>
|
||||
<use href="#x"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body record-confirmation"><span class="confirm-icon"><svg><use href="#trash"/></svg></span>
|
||||
<div><strong id="recordDeletePrompt"></strong>
|
||||
<p data-i18n="modal.delete_history_warning"></p></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="secondary-button" type="button" data-close-modal data-i18n="modal.cancel"></button>
|
||||
<button class="primary-button danger-button" type="button" id="confirmDeleteHistory">
|
||||
<svg>
|
||||
<use href="#trash"/>
|
||||
</svg>
|
||||
<span data-i18n="modal.confirm_delete"></span></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,20 +7,18 @@
|
||||
5. 新增单独保存作品信息功能
|
||||
6. 重构项目内置请求延时机制
|
||||
7. 新增对 RedNote 的支持
|
||||
8. 支持图形用户交互界面
|
||||
|
||||
*****
|
||||
|
||||
**用户脚本更新内容:**
|
||||
|
||||
**版本号:2.4.6**
|
||||
**版本号:2.4.2**
|
||||
|
||||
1. 修复部分视频下载失败的问题
|
||||
2. 优化视频下载链接提取逻辑
|
||||
3. 优化显示位置,避免遮挡内容
|
||||
4. 支持手动选择视频下载质量
|
||||
5. 支持自定义文件名称格式
|
||||
6. 优化链接提取的内容格式
|
||||
7. 支持设置视频下载偏好
|
||||
8. 脚本图标支持移动位置
|
||||
9. 新增对 RedNote 的支持
|
||||
10. 调整图标显示位置
|
||||
4. 支持自定义文件名称格式
|
||||
5. 脚本图标支持移动位置
|
||||
6. 新增对 RedNote 的支持
|
||||
7. 调整图标显示位置
|
||||
|
||||
+10
-316
@@ -2,7 +2,7 @@
|
||||
// @name XHS-Downloader
|
||||
// @namespace xhs_downloader
|
||||
// @homepage https://github.com/JoeanAmier/XHS-Downloader
|
||||
// @version 2.4.6
|
||||
// @version 2.4.2
|
||||
// @tag 小红书
|
||||
// @tag RedNote
|
||||
// @tag XiaoHongShu
|
||||
@@ -130,19 +130,6 @@ KS-Downloader(快手、KuaiShou):https://github.com/JoeanAmier/KS-Download
|
||||
scriptServerSwitchDesc: '启用后,可以把作品下载任务推送至服务器',
|
||||
imageDownloadFormatLabel: '图片下载格式',
|
||||
imageDownloadFormatDesc: '图文作品文件下载格式',
|
||||
videoPreferenceLabel: '视频下载偏好',
|
||||
videoPreferenceDesc: '视频作品文件下载策略',
|
||||
videoPreferenceOptions: {
|
||||
manual: '手动选择',
|
||||
resolution: '分辨率优先',
|
||||
bitrate: '码率优先',
|
||||
size: '文件大小优先',
|
||||
},
|
||||
videoQualityTitle: '选择视频下载质量',
|
||||
videoQualityOriginal: '最高质量',
|
||||
videoQualityBitrate: '码率',
|
||||
videoQualityFps: '帧率',
|
||||
videoQualitySize: '大小',
|
||||
fileNameFormatLabel: '作品文件名称格式',
|
||||
fileNameFormatDesc: '点击可选字段添加,拖拽已选字段调整顺序',
|
||||
fileNameSelectedLabel: '已选字段',
|
||||
@@ -299,19 +286,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
scriptServerSwitchDesc: 'When enabled, download tasks can be pushed to the server',
|
||||
imageDownloadFormatLabel: 'Image Download Format',
|
||||
imageDownloadFormatDesc: 'Preferred file format for downloading images',
|
||||
videoPreferenceLabel: 'Video download preferences',
|
||||
videoPreferenceDesc: 'Video Notes File Download Strategy',
|
||||
videoPreferenceOptions: {
|
||||
manual: 'Manual select',
|
||||
resolution: 'Resolution priority',
|
||||
bitrate: 'Bitrate priority',
|
||||
size: 'File size priority',
|
||||
},
|
||||
videoQualityTitle: 'Select Video Download Quality',
|
||||
videoQualityOriginal: 'Highest quality',
|
||||
videoQualityBitrate: 'Bitrate',
|
||||
videoQualityFps: 'Frame rate',
|
||||
videoQualitySize: 'Size',
|
||||
fileNameFormatLabel: 'Note File Name Format',
|
||||
fileNameFormatDesc: 'Click available fields to add them. Drag selected fields to reorder.',
|
||||
fileNameSelectedLabel: 'Selected Fields',
|
||||
@@ -446,7 +420,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
linkCheckboxSwitch: GM_getValue("linkCheckboxSwitch", true),
|
||||
imageCheckboxSwitch: GM_getValue("imageCheckboxSwitch", true),
|
||||
imageDownloadFormat: GM_getValue("imageDownloadFormat", "jpeg"),
|
||||
videoPreference: GM_getValue("videoPreference", "resolution"),
|
||||
scriptServerURL: GM_getValue("scriptServerURL", defaultsWebSocketURL),
|
||||
scriptServerSwitch: GM_getValue("scriptServerSwitch", false),
|
||||
fileNameFormatKeys: storedFileNameFormatKeys,
|
||||
@@ -587,11 +560,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
GM_setValue("imageDownloadFormat", config.imageDownloadFormat);
|
||||
}
|
||||
|
||||
const updateVideoPreference = (value) => {
|
||||
config.videoPreference = value;
|
||||
GM_setValue("videoPreference", value);
|
||||
};
|
||||
|
||||
// 更新文件名格式配置。
|
||||
const updateFileNameFormat = (value) => {
|
||||
config.fileNameFormatKeys = parseFileNameFormat(value);
|
||||
@@ -624,21 +592,13 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
}
|
||||
}
|
||||
|
||||
const preferenceKey = {
|
||||
resolution: "height",
|
||||
bitrate: "videoBitrate",
|
||||
size: "size",
|
||||
};
|
||||
|
||||
const generateVideoUrl = note => {
|
||||
try {
|
||||
const key = note.video?.consumer?.originVideoKey;
|
||||
if (key) return [`https://sns-video-bd.xhscdn.com/${key}`];
|
||||
const allStreams = Object.values(note.video.media.stream).flat();
|
||||
if (allStreams.length === 0) return [];
|
||||
sortArray(allStreams, preferenceKey[config.videoPreference]);
|
||||
const stream = allStreams[0];
|
||||
return [stream.backupUrls[0] || stream.masterUrl];
|
||||
sortArray(allStreams, "height");
|
||||
return [allStreams[0].backupUrls[0] || allStreams[0].masterUrl];
|
||||
} catch (error) {
|
||||
console.error("Error deal video URL:", error);
|
||||
return [];
|
||||
@@ -657,42 +617,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
});
|
||||
}
|
||||
|
||||
const generateVideoStreams = note => {
|
||||
const streams = [];
|
||||
const originKey = note.video?.consumer?.originVideoKey;
|
||||
|
||||
if (originKey) {
|
||||
streams.push({
|
||||
url: `https://sns-video-bd.xhscdn.com/${originKey}`, isOriginal: true,
|
||||
});
|
||||
}
|
||||
|
||||
let renditions;
|
||||
try {
|
||||
renditions = Object.values(note.video.media?.stream || {})
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map(s => ({
|
||||
url: s.backupUrls?.[0] || s.masterUrl,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
bitrate: s.videoBitrate,
|
||||
fps: s.fps,
|
||||
size: s.size,
|
||||
qualityType: s.qualityType,
|
||||
}))
|
||||
.filter(s => s.url);
|
||||
} catch (error) {
|
||||
console.error("Error generate video streams:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
renditions.sort((a, b) => (b.height ?? 0) - (a.height ?? 0));
|
||||
streams.push(...renditions);
|
||||
|
||||
return streams;
|
||||
};
|
||||
|
||||
const generateImageUrl = note => {
|
||||
let images = note.imageList;
|
||||
const regex = detailUrlPattern[currentSite]["normal"];
|
||||
@@ -776,33 +700,13 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
}
|
||||
};
|
||||
|
||||
// 选择最终视频下载链接:手动模式下弹出选择窗口,其余沿用 generateVideoUrl 的自动选择
|
||||
// 返回 null 表示用户取消(不下载也不报错),[] 表示无可用流,[url] 表示确定下载
|
||||
const selectVideoStream = async (note, server) => {
|
||||
if (config.videoPreference !== "manual") {
|
||||
return generateVideoUrl(note);
|
||||
}
|
||||
|
||||
const streams = generateVideoStreams(note);
|
||||
if (streams.length === 0) return [];
|
||||
|
||||
if (!server && streams.length > 1) {
|
||||
const selected = await showVideoStreamSelectionModal(streams);
|
||||
if (!selected) return null;
|
||||
return [selected.url];
|
||||
}
|
||||
|
||||
return [streams[0].url];
|
||||
};
|
||||
|
||||
const exploreDeal = async (note, server = false,) => {
|
||||
try {
|
||||
let links;
|
||||
if (note.type === "normal") {
|
||||
links = generateImageUrl(note);
|
||||
} else {
|
||||
links = await selectVideoStream(note, server);
|
||||
if (links === null) return; // 用户取消
|
||||
links = generateVideoUrl(note);
|
||||
}
|
||||
if (links.length > 0) {
|
||||
// console.debug("下载链接", links);
|
||||
@@ -1258,9 +1162,9 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
|
||||
const generateNoteUrls = data => data.map(
|
||||
([id, token,]) => `https://www.xiaohongshu.com/discovery/item/${id}?source=webshare&xhsshare=pc_web&xsec_token=${token}&xsec_source=pc_share`)
|
||||
.join("\n");
|
||||
.join(" ");
|
||||
|
||||
const generateUserUrls = data => data.map(id => `https://www.${currentSite}.com/user/profile/${id}`).join("\n");
|
||||
const generateUserUrls = data => data.map(id => `https://www.${currentSite}.com/user/profile/${id}`).join(" ");
|
||||
|
||||
const invalidDetection = data => data.every(([first]) => Boolean(first));
|
||||
|
||||
@@ -1538,8 +1442,7 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
box-shadow: 0 0 4px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
.select-input {
|
||||
min-width: 130px;
|
||||
width: auto;
|
||||
width: 100px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
@@ -1770,200 +1673,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
});
|
||||
}
|
||||
|
||||
(() => {
|
||||
if (!document.getElementById('videoStreamStyle')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'videoStreamStyle';
|
||||
style.textContent = `
|
||||
.video-quality-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.video-quality-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.video-quality-item:hover {
|
||||
background: #fafafa;
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
.video-quality-item.selected {
|
||||
border-color: #2196F3;
|
||||
box-shadow: 0 0 0 4px rgba(33,150,243,0.12) inset;
|
||||
}
|
||||
.video-quality-radio {
|
||||
margin-right: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.video-quality-info {
|
||||
flex: 1;
|
||||
}
|
||||
.video-quality-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.video-quality-sub {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 创建单个视频流选项(供 showVideoStreamSelectionModal 使用)
|
||||
* @param {Object} stream - 视频流描述符
|
||||
* @param {number} index - 序号
|
||||
* @param {(item: HTMLLabelElement) => void} onSelect - 选中回调
|
||||
* @returns {HTMLLabelElement}
|
||||
*/
|
||||
const createStreamOption = (stream, index, onSelect) => {
|
||||
const item = document.createElement('label');
|
||||
item.className = 'video-quality-item';
|
||||
|
||||
const radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'xhsVideoStream';
|
||||
radio.value = index;
|
||||
radio.checked = index === 0;
|
||||
radio.className = 'video-quality-radio';
|
||||
radio.onchange = () => onSelect(item);
|
||||
|
||||
const titleParts = [];
|
||||
if (stream.width && stream.height) {
|
||||
titleParts.push(`${stream.width}×${stream.height}`);
|
||||
}
|
||||
if (stream.isOriginal) {
|
||||
titleParts.push(t.videoQualityOriginal);
|
||||
} else if (stream.qualityType) {
|
||||
titleParts.push(stream.qualityType);
|
||||
}
|
||||
|
||||
const subParts = [];
|
||||
if (stream.bitrate) subParts.push(`${t.videoQualityBitrate}: ${(stream.bitrate / 1000).toFixed(0)}kbps`);
|
||||
if (stream.fps) subParts.push(`${t.videoQualityFps}: ${stream.fps}fps`);
|
||||
if (stream.size) subParts.push(`${t.videoQualitySize}: ${(stream.size / 1024 / 1024).toFixed(2)}MB`);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'video-quality-info';
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.className = 'video-quality-title';
|
||||
titleDiv.textContent = titleParts.join(' ');
|
||||
info.appendChild(titleDiv);
|
||||
const subDiv = document.createElement('div');
|
||||
subDiv.className = 'video-quality-sub';
|
||||
if (subParts.length) {
|
||||
subDiv.textContent = subParts.join(' | ');
|
||||
info.appendChild(subDiv);
|
||||
}
|
||||
|
||||
item.appendChild(radio);
|
||||
item.appendChild(info);
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示视频清晰度选择弹窗
|
||||
* @param {Array<{
|
||||
* url:string,
|
||||
* width?:number,
|
||||
* height?:number,
|
||||
* bitrate?:number,
|
||||
* fps?:number,
|
||||
* size?:number,
|
||||
* qualityType?:string,
|
||||
* isOriginal?:boolean
|
||||
* }>} streams - 可选视频流列表
|
||||
* @returns {Promise<Object|null>} 选中的视频流对象,取消返回 null
|
||||
*/
|
||||
function showVideoStreamSelectionModal(streams) {
|
||||
if (document.getElementById('videoStreamOverlay')) return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'videoStreamOverlay';
|
||||
overlay.style.cssText = `position: fixed; inset: 0; background: rgba(0,0,0,0.32); backdrop-filter: blur(4px); display: flex; justify-content: center; align-items: center; z-index: 10000; animation: fadeIn 0.3s;`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'text-generic-modal';
|
||||
modal.style.maxWidth = '500px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const headerTitle = document.createElement('span');
|
||||
headerTitle.textContent = t.videoQualityTitle;
|
||||
header.appendChild(headerTitle);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body video-quality-body';
|
||||
|
||||
let selectedItem = null;
|
||||
const selectItem = (item) => {
|
||||
if (selectedItem) selectedItem.classList.remove('selected');
|
||||
selectedItem = item;
|
||||
item.classList.add('selected');
|
||||
};
|
||||
streams.forEach((stream, index) => {
|
||||
const item = createStreamOption(stream, index, selectItem);
|
||||
if (index === 0) selectItem(item);
|
||||
body.appendChild(item);
|
||||
});
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'modal-footer';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'primary-btn';
|
||||
confirmBtn.textContent = t.startDownloadButton;
|
||||
confirmBtn.onclick = () => {
|
||||
const selected = modal.querySelector('input[name="xhsVideoStream"]:checked');
|
||||
close(selected ? streams[selected.value] : null);
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'secondary-btn';
|
||||
cancelBtn.textContent = t.closeDownloadButton;
|
||||
cancelBtn.onclick = () => close(null);
|
||||
|
||||
footer.appendChild(confirmBtn);
|
||||
footer.appendChild(cancelBtn);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
let closed = false;
|
||||
|
||||
function close(result) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
|
||||
overlay.style.animation = 'fadeOut 0.2s';
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 创建开关项
|
||||
const createSwitchItem = ({label, description, checked, disabled = false}) => {
|
||||
const item = document.createElement('div');
|
||||
@@ -2210,11 +1919,8 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
item.style.opacity = disabled ? 0.6 : 1;
|
||||
|
||||
// 生成选项HTML
|
||||
const optionsHtml = options.map(option => {
|
||||
const optionValue = typeof option === 'object' ? option.value : option;
|
||||
const optionLabel = typeof option === 'object' ? option.label : option;
|
||||
return `<option value="${optionValue}" ${optionValue === value ? 'selected' : ''}>${optionLabel}</option>`;
|
||||
}).join('');
|
||||
const optionsHtml = options.map(
|
||||
option => `<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`).join('');
|
||||
|
||||
item.innerHTML = `
|
||||
<label>
|
||||
@@ -2327,17 +2033,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
.toUpperCase(),
|
||||
});
|
||||
|
||||
const videoPreference = createSelectItem({
|
||||
label: t.videoPreferenceLabel,
|
||||
description: t.videoPreferenceDesc,
|
||||
options: Object.entries(t.videoPreferenceOptions)
|
||||
.map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
value: config.videoPreference,
|
||||
});
|
||||
|
||||
const nameFormat = createFileNameFormatEditor({
|
||||
label: t.fileNameFormatLabel,
|
||||
description: t.fileNameFormatDesc,
|
||||
@@ -2373,7 +2068,7 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
// 组合内容
|
||||
body.appendChild(createSettingsGroup(
|
||||
t.downloadSettingsGroup,
|
||||
[filePack, imageCheckboxSwitch, imageDownloadFormat, videoPreference, nameFormat],
|
||||
[filePack, imageCheckboxSwitch, imageDownloadFormat, nameFormat],
|
||||
true
|
||||
));
|
||||
body.appendChild(createSettingsGroup(t.extractSettingsGroup, [autoScroll, scrollCount, linkCheckboxSwitch]));
|
||||
@@ -2410,7 +2105,6 @@ Discord Community: https://discord.com/invite/ZYtmgKud9Y
|
||||
updateScriptServerURL(scriptServerURL.querySelector('.text-input').value.trim() || defaultsWebSocketURL);
|
||||
updateScriptServerSwitch(scriptServerSwitch.querySelector('input').checked);
|
||||
updateImageDownloadFormat(imageDownloadFormat.querySelector('select').value.trim() || "jpeg");
|
||||
updateVideoPreference(videoPreference.querySelector('select').value);
|
||||
updateFileNameFormat(nameFormat.getFileNameFormatKeys());
|
||||
closeSettingsModal();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user