feat(app): 支持图形用户交互界面

This commit is contained in:
Quan
2026-08-05 20:00:11 +08:00
parent cfc064f246
commit ade727f15a
28 changed files with 6610 additions and 1215 deletions
+3 -3
View File
@@ -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
View File
@@ -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>
+6 -7
View File
@@ -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
View File
@@ -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",
]
+2
View File
@@ -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
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
"""PyWebView GUI 包。"""
from .launcher import get_index_path, launch
__all__ = ["get_index_path", "launch"]
+844
View File
@@ -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())
+79
View File
@@ -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()
+256
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+30 -6
View File
@@ -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,
+2
View File
@@ -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,
+16 -6
View File
@@ -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()
+1 -1
View File
@@ -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):
+46 -3
View File
@@ -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()
+5 -3
View File
@@ -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"
+6 -5
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+636
View File
@@ -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
+6 -8
View File
@@ -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
View File
@@ -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();
});
Generated
+178 -720
View File
File diff suppressed because it is too large Load Diff