From d1d9fd20415248afeb5e32b5ce0072670adb8bab Mon Sep 17 00:00:00 2001 From: json Date: Mon, 11 May 2026 18:43:47 +0800 Subject: [PATCH 01/11] fix: proxy without domain-cache runtime Err --- assets/res/locale/en_US.yml | 2 -- assets/res/locale/zh_CN.yml | 2 -- utils/website/hitomi/scape_dataset.py | 1 - utils/website/providers/jm.py | 1 + utils/website/providers/wnacg.py | 1 + variables/__init__.py | 2 +- 6 files changed, 3 insertions(+), 6 deletions(-) diff --git a/assets/res/locale/en_US.yml b/assets/res/locale/en_US.yml index 57632daa..a501f853 100644 --- a/assets/res/locale/en_US.yml +++ b/assets/res/locale/en_US.yml @@ -156,8 +156,6 @@ EHentai: ACCESS_FAIL: "Current `eh-cookies`/proxy configuration cannot access exhentai
Verify site accessibility (direct CN access unsupported)" GUIDE: "exhentai (EH) Usage Guide:
1. Ensure account with `exhentai.org` access
- Configure `eh-cookies` per setup instructions
2. (CN Users) Ensure working proxy (no direct access)
- Use global proxy or configure proxy before launch
⚠️ API test uses 3s timeout to prevent UI freeze - check `GUI.log` for debug info if errors persist" JUMP_TIP: "EH pagination is special - page jump feature currently disabled" - MAPPINGS_INDEX: "index" - MAPPINGS_POPULAR: "popular" SPIDER: SayToGui: diff --git a/assets/res/locale/zh_CN.yml b/assets/res/locale/zh_CN.yml index 036d050c..674663c9 100644 --- a/assets/res/locale/zh_CN.yml +++ b/assets/res/locale/zh_CN.yml @@ -156,8 +156,6 @@ EHentai: ACCESS_FAIL: "当前`cookies-eh`或`配置代理`或`全局代理`等环境无法访问
请前往网站访问排查(尚不支持该网站墙内直连)" GUIDE: "exhentai使用指引(里站,非表站)
1. 确保你有一个能访问`exhentai.org`的账号
- 配置需设置`cookies-eh`的值,值生成参考配置详情里该字段说明。
2. (国内)确保你有一个可以使用的代理(不支持无代理直连)
- 可使用全局代理,或者配置代理" JUMP_TIP: "ehentai跳页情况特殊,没想好应用,暂时设限制取消`跳页`功能" - MAPPINGS_INDEX: "首页" - MAPPINGS_POPULAR: "热门" SPIDER: SayToGui: diff --git a/utils/website/hitomi/scape_dataset.py b/utils/website/hitomi/scape_dataset.py index b5b30bae..a5f55fae 100644 --- a/utils/website/hitomi/scape_dataset.py +++ b/utils/website/hitomi/scape_dataset.py @@ -9,7 +9,6 @@ import httpx from lxml import html -# TODO[0](2026-05-05): ratelimit from assets import res from utils import temp_p diff --git a/utils/website/providers/jm.py b/utils/website/providers/jm.py index 2de11b31..440138dc 100644 --- a/utils/website/providers/jm.py +++ b/utils/website/providers/jm.py @@ -23,6 +23,7 @@ class _JmContract: name = "jm" + domain = "18comic.vip" # REMARK(CG001): Keep a static fallback domain so GUI/runtime can bind before jm_domain.txt is rebuilt. forever_url = "https://jm365.work/3YeBdF" publish_url = "https://jm365.work/mJ8rWd" publish_url2 = "https://jmcomicne.net/" diff --git a/utils/website/providers/wnacg.py b/utils/website/providers/wnacg.py index dbcd9a26..5f0ff659 100644 --- a/utils/website/providers/wnacg.py +++ b/utils/website/providers/wnacg.py @@ -20,6 +20,7 @@ class _WnacgContract: name = "wnacg" + domain = "wnacg.com" # REMARK(CG001): GUI runtime needs this static fallback when wnacg_domain.txt is absent; do not delete. publish_domain = "wnacg01.link" publish_domain_old = ["wnacg.date", "wn01.link"] publish_url = f"https://{publish_domain}" diff --git a/variables/__init__.py b/variables/__init__.py index 70ed9fcd..995ba96e 100644 --- a/variables/__init__.py +++ b/variables/__init__.py @@ -76,7 +76,7 @@ def _label(s: Spider) -> str: Spider.HITOMI: ['index-all', 'popular/week-all', 'popular/month-all'], Spider.H_COMIC: ['更新'], Spider.NHENTAI: [res.SPIDER.Completer.update], - Spider.JESTFUL: [res.SPIDER.Completer.index], + Spider.JESTFUL: [res.SPIDER.Completer.index,res.SPIDER.Completer.update], Spider.MANHUAGUI: [res.SPIDER.Completer.index,res.SPIDER.Completer.update], }), SCRIPT_SITE_INDEX: [], From a910a490103a4fcafbfa6f3a98e66dc4e000428f Mon Sep 17 00:00:00 2001 From: json Date: Fri, 15 May 2026 22:01:49 +0800 Subject: [PATCH 02/11] feat: cgs-share fix: cache --- GUI/gui.py | 22 ++- GUI/mainwindow.py | 3 + GUI/manager/__init__.py | 3 +- GUI/manager/download.py | 4 + GUI/manager/preview/__init__.py | 18 +++ GUI/manager/share.py | 236 +++++++++++++++++++++++++++++ GUI/manager/task_progress.py | 76 ++++++++-- GUI/uic/qfluent/components/cust.py | 19 ++- docs/.vitepress/config.ts | 2 +- docs/_github/preset_preview.md | 3 +- docs/config/index.md | 5 + utils/config/__init__.py | 1 + utils/core.py | 1 + utils/share/__init__.py | 14 ++ utils/share/discord_api.py | 114 ++++++++++++++ utils/share/preview_gen.py | 85 +++++++++++ utils/share/serializer.py | 99 ++++++++++++ utils/website/site_runtime.py | 7 +- variables/__init__.py | 1 + 19 files changed, 691 insertions(+), 22 deletions(-) create mode 100644 GUI/manager/share.py create mode 100644 utils/share/__init__.py create mode 100644 utils/share/discord_api.py create mode 100644 utils/share/preview_gen.py create mode 100644 utils/share/serializer.py diff --git a/GUI/gui.py b/GUI/gui.py index a069ca40..e967b616 100644 --- a/GUI/gui.py +++ b/GUI/gui.py @@ -28,7 +28,7 @@ from GUI.manager import ( TaskProgressManager, ClipGUIManager, AggrSearchManager, RVManager, CGSMidManagerGUI, PreviewMgr, UpdateNotifier, PublishDomainManager, - SelectionFlowManager, DownloadRuntimeManager + SelectionFlowManager, DownloadRuntimeManager, Shares ) from utils.config.qc import cgs_cfg from GUI.manager.preprocess import PreprocessManager @@ -134,6 +134,7 @@ def generation_bind(self): self.ags_mgr = AggrSearchManager(self) self.preview_mgr = PreviewMgr(self) self.publish_mgr = PublishDomainManager(self) + self.shares = Shares(self) self.download_state = DownloadStateStore() self.dl_mgr = DownloadRuntimeManager(self) self.sel_mgr = SelectionFlowManager(self) @@ -346,6 +347,9 @@ def btn_logic_bind(self): self.aggrBtn.clicked.connect(lambda: self.show_toolWin("ags")) self.htBtn.clicked.connect(lambda: self.show_toolWin("hitomi")) self.openPBtn.clicked.connect(lambda: curr_os.open_folder(self.sv_path)) + self.shareBtn.clicked.connect(self.shares.upload) + self.shares.changed.connect(self._sync_share_btn_visible) + self._sync_share_btn_visible() self.domainBtn.clicked.connect(self.do_publish) _safe_disconnect(self.mpreviewBtn.clicked) @@ -353,6 +357,9 @@ def btn_logic_bind(self): self.page_turn_frame() + def _sync_share_btn_visible(self): + self.shareBtn.setVisible(not self.shares.is_empty()) + def page_turn_frame(self): def page_turn(_p): if not hasattr(self, "preview_mgr"): @@ -530,6 +537,16 @@ def start_and_search(self, keyword=None, site_index=None): if keyword: self.searchinput.setText(keyword) kw = self.searchinput.text().strip() + if kw.startswith("dc:"): + share_id = kw[3:].strip() + if not share_id: + InfoBar.info( + title='', content='请输入 dc:shareID', isClosable=True, + position=InfoBarPosition.BOTTOM, duration=2000, parent=self.textBrowser + ) + return + self.shares.download(share_id) + return if not kw: InfoBar.info( title='', content='先输入搜索词吧', isClosable=True, @@ -683,4 +700,7 @@ def _show_skip_info(self, skip_info: dict): if tips: self.say(font_color(f"已跳过:{','.join(tips)}", cls='theme-tip'), ignore_http=True) + def publish_share_books(self, books): + self.preview_mgr.publish_share_books(books) + # --- diff --git a/GUI/mainwindow.py b/GUI/mainwindow.py index 6bfb22f6..c8057a27 100644 --- a/GUI/mainwindow.py +++ b/GUI/mainwindow.py @@ -170,6 +170,7 @@ def preset(self): def task_init(self): self.expandBtn = ExpandButton(self) + self.shareBtn = TransparentToolButton(FIF.SHARE, self) self.clearBtn = TransparentToolButton(FIF.BROOM) self.repairBtn = TransparentToolButton(QIcon(':/main/repair.svg'), self) self.repairBtn.setStatusTip("Patch missing page/补漏页") @@ -185,10 +186,12 @@ def task_init(self): self.scroll_area.setWidgetResizable(True) self.barHLayout.insertWidget(0, self.expandBtn) + self.barHLayout.addWidget(self.shareBtn) self.barHLayout.addWidget(self.repairBtn) self.barHLayout.addWidget(self.clearBtn) self.barVLayout.addWidget(self.scroll_area) + self.shareBtn.setVisible(False) self.expandBtn.setVisible(False) self.repairBtn.setVisible(False) self.clearBtn.setVisible(False) diff --git a/GUI/manager/__init__.py b/GUI/manager/__init__.py index 188ee18e..6c5e2ab3 100644 --- a/GUI/manager/__init__.py +++ b/GUI/manager/__init__.py @@ -25,13 +25,14 @@ from GUI.manager.publish import PublishDomainManager from GUI.manager.selection import SelectionFlowManager from GUI.manager.download import DownloadRuntimeManager +from GUI.manager.share import Shares __all__ = [ 'Updater', 'UpdateNotifier', 'TaskConfig', 'RVManager', 'TaskProgressManager','AsyncTaskManager', 'ClipGUIManager', 'AggrSearchManager', 'CGSMidManagerGUI', 'PreviewMgr', 'PublishDomainManager', - 'SelectionFlowManager', 'DownloadRuntimeManager' + 'SelectionFlowManager', 'DownloadRuntimeManager', 'Shares' ] diff --git a/GUI/manager/download.py b/GUI/manager/download.py index e7bf0440..960f82c6 100644 --- a/GUI/manager/download.py +++ b/GUI/manager/download.py @@ -80,6 +80,10 @@ def resubmit_download(self, task_id: str): site_index, task_info = self._submitted_task_infos[task_id] self.submit_download(deepcopy(task_info), site_index=site_index) + def build_share_payload(self, task_id: str) -> dict: + site_index, task_info = self._submitted_task_infos[task_id] + return self.gui.shares.build_share_payload(deepcopy(task_info)) + def ensure_work_thread(self) -> WorkThread: if self.b_thread and self.b_thread.isRunning(): return self.b_thread diff --git a/GUI/manager/preview/__init__.py b/GUI/manager/preview/__init__.py index 350f37a8..82720343 100644 --- a/GUI/manager/preview/__init__.py +++ b/GUI/manager/preview/__init__.py @@ -133,6 +133,24 @@ def show_preview(self, *, ensure_handler=None, reload_tf=True, bridge=None): self._bind_page_interactive(browser) return browser + def publish_share_books(self, books): + if not books: + raise ValueError("share books is empty") + first = books[0] + site_index = next( + (idx for idx, spider_name in SPIDERS.items() if spider_name == getattr(first, "source", "")), + None, + ) + if site_index is None: + raise ValueError(f"unsupported share source: {getattr(first, 'source', '')}") + if self.gui.chooseBox.currentIndex() != site_index: + self.gui.chooseBox.setCurrentIndex(site_index) + self.begin_preview_session() + self.gui.flow_stage = GUIFlowStage.SEARCHED + self._current_page = 1 + self._target_page = None + self._active.publish(books) + def _legacy_run_js(self, js, session_id): if session_id != self._session_id: return False diff --git a/GUI/manager/share.py b/GUI/manager/share.py new file mode 100644 index 00000000..74194981 --- /dev/null +++ b/GUI/manager/share.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import typing as t +from copy import deepcopy +from dataclasses import dataclass + +from PySide6.QtCore import Qt, QObject, Signal +from qfluentwidgets import InfoBar, InfoBarPosition + +from GUI.manager.async_task import AsyncTaskManager +from utils import conf +from utils.share import DiscordShareAPI, DiscordSharePayloadTooLargeError, build_cover_bytes, deserialize_books, serialize_books +from utils.website.info import BookInfo, Episode +from variables import CGS_DISCORD_SHARE_API + +_UPLOAD_SIZE_LIMIT_BYTES = 10 * 1024 * 1024 +SHARE_UPLOAD_MAX_BOOKS = 10 + + +@dataclass(slots=True) +class ShareUploadResult: + share_id: str + site: str + book_count: int + total_pages: int + uploaded_urls: tuple[str, ...] + + +class Shares(QObject): + changed = Signal() + upload_started = Signal() + upload_finished = Signal(object) + + def __init__(self, gui): + parent = gui if isinstance(gui, QObject) else None + super().__init__(parent) + self.gui = gui + self._items: list[BookInfo] = [] + self.site = "" + self._uploading = False + self.task_mgr = AsyncTaskManager(gui) + + @property + def items(self) -> list[BookInfo]: + return self.snapshot() + + def is_empty(self) -> bool: + return not self._items + + def is_uploading(self) -> bool: + return self._uploading + + def count(self) -> int: + return len(self._items) + + def snapshot(self) -> list[BookInfo]: + return [deepcopy(item) for item in self._items] + + def contains_url(self, url: str) -> bool: + target = self._normalize_url(url) + if not target: + return False + return any(self._normalize_url(getattr(item, "url", None)) == target for item in self._items) + + def clear(self): + self._items.clear() + self.site = "" + self.changed.emit() + + def add(self, book: BookInfo): + if not isinstance(book, BookInfo): + raise TypeError(f"share item must be BookInfo, got {type(book).__name__}") + if not getattr(book, "source", ""): + raise ValueError("share item missing source") + book_url = self._normalize_url(getattr(book, "url", None)) + if not book_url: + raise ValueError("share item missing url") + if self.site and book.source != self.site: + raise ValueError(f"curr share only support {self.site}") + if any(self._normalize_url(getattr(item, "url", None)) == book_url for item in self._items): + return False + if len(self._items) >= SHARE_UPLOAD_MAX_BOOKS: + raise ValueError(f"share batch full (max {SHARE_UPLOAD_MAX_BOOKS})") + if not self.site: + self.site = book.source + cloned = deepcopy(book) + cloned.local_path = getattr(book, "local_path", None) + self._items.append(cloned) + self.changed.emit() + return True + + def remove_by_url(self, url: str): + target_url = self._normalize_url(url) + remained = [item for item in self._items if self._normalize_url(getattr(item, "url", None)) != target_url] + if len(remained) == len(self._items): + return + self._items = remained + if not self._items: + self.site = "" + self.changed.emit() + + def total_pages(self, books: t.Iterable[BookInfo] | None = None) -> int: + total = 0 + for book in self._items if books is None else books: + total += int(getattr(book, "pages", 0) or 0) + for episode in list(getattr(book, "episodes", None) or []): + total += int(getattr(episode, "pages", 0) or 0) + return total + + @staticmethod + def rebuild_from_share_payload(payload: dict) -> BookInfo: + book = deepcopy(payload.get("book")) + book.local_path = str(payload.get("local_path") or "") + return book + + @staticmethod + def build_share_payload(task_info) -> dict: + if isinstance(task_info, Episode): + source_book = deepcopy(task_info.from_book) + source_book.episodes = [deepcopy(task_info)] + source_book.episodes[0].from_book = source_book + target = source_book + elif isinstance(task_info, BookInfo): + target = deepcopy(task_info) + for episode in list(getattr(target, "episodes", None) or []): + episode.from_book = target + else: + raise TypeError(f"unsupported share payload source: {type(task_info).__name__}") + return {"book": target, "local_path": None} + + def _current_user_token(self) -> str: + return str(conf.discord_share_user_token or "").strip() + + def _current_api_url(self) -> str: + return str(CGS_DISCORD_SHARE_API or "").strip() + + def upload(self): + upload_books = self.snapshot() + self._uploading = True + self.upload_started.emit() + started = self.task_mgr.execute_simple_task( + self._upload_impl, + success_callback=self._on_upload_success, + error_callback=self._on_upload_error, + tooltip_title="discord share", + tooltip_content="准备上传", + success_message="分享上传完成", + task_id="discord_share_upload", + show_success_info=False, + books_snapshot=upload_books, + ) + if not started: + self._uploading = False + self.upload_finished.emit(None) + return started + + async def _upload_impl(self, books_snapshot: list[BookInfo], *, progress_callback=None): + if progress_callback is not None: + progress_callback("生成分享文件") + payload_bytes = serialize_books(books_snapshot) + if progress_callback is not None: + progress_callback("生成封面") + covers: list[tuple[str, bytes]] = [] + book_names: list[str] = [] + uploaded_urls: list[str] = [] + for book in books_snapshot: + cover_bytes = build_cover_bytes(book) + covers.append((getattr(book, "name", "") or "", cover_bytes)) + book_names.append(getattr(book, "name", "") or "") + uploaded_urls.append(self._normalize_url(getattr(book, "url", None))) + payload_size = len(payload_bytes) + sum(len(cover_bytes) for _name, cover_bytes in covers) + if payload_size > _UPLOAD_SIZE_LIMIT_BYTES: + raise DiscordSharePayloadTooLargeError(payload_size, limit_bytes=_UPLOAD_SIZE_LIMIT_BYTES) + if progress_callback is not None: + progress_callback("上传到 Discord share") + api = DiscordShareAPI(self._current_api_url(), self._current_user_token()) + share_id = await api.upload_share( + payload_bytes=payload_bytes, + covers=covers, + site=self._site_for_books(books_snapshot), + book_names=book_names, + ) + return ShareUploadResult( + share_id=share_id, + site=self._site_for_books(books_snapshot), + book_count=len(books_snapshot), + total_pages=self.total_pages(books_snapshot), + uploaded_urls=tuple(uploaded_urls), + ) + + def _on_upload_success(self, result: ShareUploadResult): + self._uploading = False + InfoBar.success( + title='', content=f"分享上传完成 {result.share_id}", + orient=Qt.Horizontal, isClosable=True, + position=InfoBarPosition.BOTTOM, duration=3000, parent=self.gui, + ) + self.upload_finished.emit(result) + + def _on_upload_error(self, _error: str): + self._uploading = False + self.upload_finished.emit(None) + + def download(self, share_id: str): + return self.task_mgr.execute_simple_task( + self._download_impl, + success_callback=self._on_download_success, + tooltip_title="discord share", + tooltip_content="下载分享内容", + success_message="分享已载入预览", + task_id=f"discord_share_download_{share_id}", + show_success_info=False, + share_id=share_id, + ) + + async def _download_impl(self, share_id: str, *, progress_callback=None): + if progress_callback is not None: + progress_callback("下载分享文件") + api = DiscordShareAPI(self._current_api_url(), self._current_user_token()) + payload = await api.download_share(share_id) + if progress_callback is not None: + progress_callback("解析分享内容") + return deserialize_books(payload) + + def _on_download_success(self, books: list[BookInfo]): + self.gui.preview_mgr.publish_share_books(books) + self.upload_finished.emit(books) + + @staticmethod + def _normalize_url(url: str | None) -> str: + return str(url or "").strip() + + def _site_for_books(self, books: list[BookInfo]) -> str: + if books: + return str(getattr(books[0], "source", "") or self.site) + return self.site diff --git a/GUI/manager/task_progress.py b/GUI/manager/task_progress.py index a9fa1acd..05bdeba0 100644 --- a/GUI/manager/task_progress.py +++ b/GUI/manager/task_progress.py @@ -8,6 +8,7 @@ from PySide6.QtGui import QGuiApplication, QPixmap, QDesktopServices from qfluentwidgets import ( ProgressBar, VBoxLayout, PrimaryToolButton, TransparentToolButton, + TransparentToggleToolButton, InfoBar, InfoBarPosition, FluentIcon as FIF, TeachingTipTailPosition, ImageLabel ) @@ -20,7 +21,7 @@ class TaskProgress: """任务进度状态(不依赖 Qt)""" - __slots__ = ("tasks_obj", "_downloaded_count", "last_percent", "completed") + __slots__ = ("tasks_obj", "_downloaded_count", "last_percent", "completed", "job_successful") def __init__(self, tasks_obj: TasksObj): self.tasks_obj = tasks_obj @@ -30,6 +31,7 @@ def __init__(self, tasks_obj: TasksObj): if tasks_obj.tasks_count > 0 else 0 ) self.completed = self.last_percent >= 100 + self.job_successful = self.completed @property def taskid(self) -> str: @@ -43,6 +45,10 @@ def tasks_count(self) -> int: def downloaded(self) -> int: return self._downloaded_count + @property + def share_ready(self) -> bool: + return self.completed and self.job_successful + def apply(self, event: TaskObj) -> int: """接收一个下载事件,更新进度,返回百分比""" self._downloaded_count += 1 @@ -52,10 +58,14 @@ def apply(self, event: TaskObj) -> int: return self.last_percent def record_job_result(self, *, success: bool, error: str | None = None) -> bool: - was_completed = self.completed + was_share_ready = self.share_ready + if success: + self.job_successful = self.completed + return was_share_ready != self.share_ready if not success: self.completed = False - return was_completed != self.completed + self.job_successful = False + return was_share_ready != self.share_ready class ProgressClass(QFrame): @@ -73,10 +83,13 @@ class ProgressClass(QFrame): "border-radius: 3px;" ) - def __init__(self, parent: QWidget, progress: TaskProgress): + def __init__(self, parent: QWidget, progress: TaskProgress, gui): super().__init__(parent) + self.gui = gui + self.progress = progress self.taskid = progress.taskid self._cover_source = None + self._share_book_url: str | None = None self.tasks_obj = progress.tasks_obj layout = VBoxLayout(self) @@ -89,11 +102,13 @@ def __init__(self, parent: QWidget, progress: TaskProgress): self.cover_label.setPixmap(QPixmap()) self.cover_label.setFixedSize(self.DEFAULT_COVER_WIDTH, self.COVER_HEIGHT) - for attr, icon, callback in ( - ("folder_btn", FIF.FOLDER, self._open_task_folder), - ("link_btn", FIF.LINK, self._open_task_link), + for attr, icon, btn_cls, callback in ( + ("folder_btn", FIF.FOLDER, TransparentToolButton, self._open_task_folder), + ("link_btn", FIF.LINK, TransparentToolButton, self._open_task_link), + ("share_btn", FIF.SHARE, TransparentToggleToolButton, self._share_task), ): - btn = TransparentToolButton(icon, self.cover_label) + btn_parent = self if attr == "share_btn" else self.cover_label + btn = btn_cls(icon, btn_parent) btn.setCursor(Qt.PointingHandCursor) btn.setFixedSize(self.ACTION_BUTTON_SIZE, self.ACTION_BUTTON_SIZE) btn.setIconSize(QSize(12, 12)) @@ -113,6 +128,7 @@ def __init__(self, parent: QWidget, progress: TaskProgress): layout.addWidget(self.progress_bar) self.setFixedWidth(self.DEFAULT_COVER_WIDTH + 8) + self.gui.shares.changed.connect(self._sync_share_checked) self.set_tasks_obj(progress.tasks_obj) @classmethod @@ -136,10 +152,18 @@ def set_tasks_obj(self, tasks_obj: TasksObj): self.title_label.setToolTip(task_name) self.folder_btn.setEnabled(bool(tasks_obj.local_path)) self.link_btn.setEnabled(bool(tasks_obj.title_url)) + self.refresh_share_button_visibility() self.page_badge.setText(f"{tasks_obj.tasks_count}P") self.page_badge.adjustSize() self._relocate_badge() + def refresh_share_button_visibility(self): + token_configured = bool(str(getattr(conf, "discord_share_user_token", "") or "").strip()) + share_visible = self.progress.share_ready and bool(self.tasks_obj.local_path) and token_configured + self.share_btn.setVisible(share_visible) + if share_visible: + self._sync_share_checked() + def _relocate_badge(self): self.page_badge.adjustSize() badge_y = self.cover_label.height() - self.page_badge.height() - self.BADGE_MARGIN @@ -147,6 +171,7 @@ def _relocate_badge(self): for widget in (self.folder_btn, self.link_btn, self.page_badge): widget.move(curr_x, badge_y) curr_x += widget.width() + self.BADGE_SPACING + self.share_btn.move(self.BADGE_MARGIN, self.BADGE_MARGIN) def _open_task_folder(self): curr_os.open_folder(self.tasks_obj.local_path) @@ -154,6 +179,37 @@ def _open_task_folder(self): def _open_task_link(self): QDesktopServices.openUrl(QUrl(self.tasks_obj.title_url)) + def _share_task(self, checked: bool): + if checked: + payload = self.gui.dl_mgr.build_share_payload(self.taskid) + book = self.gui.shares.rebuild_from_share_payload(payload) + book.local_path = self.tasks_obj.local_path + try: + self.gui.shares.add(book) + except ValueError as exc: + self._set_share_checked(False) + InfoBar.warning( + title='', content=str(exc), orient=Qt.Horizontal, isClosable=True, + position=InfoBarPosition.BOTTOM, duration=2500, parent=self.gui, + ) + return + self._share_book_url = self.gui.shares._normalize_url(book.url) + return + if self._share_book_url: + self.gui.shares.remove_by_url(self._share_book_url) + + def _sync_share_checked(self): + if not self.share_btn.isVisible() or not self._share_book_url: + return + in_batch = self.gui.shares.contains_url(self._share_book_url) + if self.share_btn.isChecked() != in_batch: + self._set_share_checked(in_batch) + + def _set_share_checked(self, checked: bool): + self.share_btn.blockSignals(True) + self.share_btn.setChecked(checked) + self.share_btn.blockSignals(False) + def _apply_cover_pixmap(self, pixmap: QPixmap) -> bool: if pixmap.isNull() or pixmap.height() <= 0: return False @@ -177,6 +233,7 @@ def set_progress(self, percent: int): self.progress_bar.setValue(percent) if percent >= 100: self.progress_bar.setCustomBarColor(light="#00ff00", dark="#00cc00") + self.refresh_share_button_visibility() def dispose(self): self.deleteLater() @@ -223,7 +280,7 @@ def mount(self): if self.view is not None: return gui = self.owner.gui - self.view = ProgressClass(gui.scroll_content, self.progress) + self.view = ProgressClass(gui.scroll_content, self.progress, gui) gui.flow_layout.addWidget(self.view) self.refresh_view() @@ -766,6 +823,7 @@ def handle_job_finished(self, task_id: str, *, success: bool, error: str | None self._completed_entries += 1 self.sync_progress_badge() if changed: + entry.refresh_view() self.display_ctrl.request_refresh() def on_cover_preload_success(self, generation: int, task_id: str, data: bytes): diff --git a/GUI/uic/qfluent/components/cust.py b/GUI/uic/qfluent/components/cust.py index ae8e2a9d..7b08a25c 100644 --- a/GUI/uic/qfluent/components/cust.py +++ b/GUI/uic/qfluent/components/cust.py @@ -8,7 +8,7 @@ from PySide6.QtWidgets import QWidget, QHBoxLayout, QGraphicsView, QGraphicsScene, QCompleter from qfluentwidgets import ( - TransparentToolButton, HyperlinkButton, PrimaryPushButton, + TransparentToolButton, HyperlinkButton, PrimaryPushButton, ToolButton, FluentIcon, FluentIconBase, Theme, LineEdit, LineEditButton, VBoxLayout, Flyout, FlyoutAnimationType, FlyoutViewBase, TableView, InfoBar, InfoBarIcon, InfoBarPosition, IndeterminateProgressBar, BodyLabel, @@ -18,6 +18,7 @@ from assets import res from GUI.core.anim import ProxyRotationController, ExpandCollapseOrchestrator, ContentTarget +from utils import conf from utils.redViewer_tools import BookShow from utils.config.qc import cgs_cfg from utils.network.doh import DEFAULT_DOH_URL @@ -403,12 +404,24 @@ def __init__(self, conf_dia=None): self.layout = VBoxLayout(self) self.titleLayout = QtWidgets.QHBoxLayout() self.qqGroupBtn = HyperlinkButton(CustomIcon.QQ, "https://qm.qq.com/q/T2SONVQmiW", "QQ") - self.discordBtn = HyperlinkButton(CustomIcon.DISCORD, "https://discord.gg/znD4p2fpSE", "Discord") + self.discordBtn = PrimaryToolButton(CustomIcon.DISCORD, self) + self.discordBtn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://discord.gg/BRx5xPpEYe"))) + discordTokenEdit = AcceptEdit(self) + discordTokenEdit.setMinimumWidth(320) + discordTokenEdit.setPlaceholderText("前往 discord 向 bot 获取..") + discordTokenEdit.setText(str(getattr(conf, "discord_share_user_token", "") or "")) + discordTokenEdit.setClearButtonEnabled(True) + def _save(): + conf.update(discord_share_user_token=str(discordTokenEdit.text() or "").strip()) + self.close() + discordTokenEdit.returnPressed.connect(_save) + discordTokenEdit.custSignal.connect(_save) spacerItem = QtWidgets.QSpacerItem(10, 10, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.closeBtn = TransparentToolButton(FluentIcon.CLOSE, self) self.closeBtn.clicked.connect(self.closed) - self.titleLayout.addWidget(self.qqGroupBtn) + self.titleLayout.addWidget(self.qqGroupBtn) self.titleLayout.addWidget(self.discordBtn) + self.titleLayout.addWidget(discordTokenEdit) self.titleLayout.addItem(spacerItem) self.titleLayout.addWidget(self.closeBtn) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9922b8df..e92cc3a8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -68,7 +68,7 @@ export default defineConfig({ icon: { svg: 'Discord' }, - link: "https://discord.gg/znD4p2fpSE" + link: "https://discord.gg/BRx5xPpEYe" }, ], nav: [ diff --git a/docs/_github/preset_preview.md b/docs/_github/preset_preview.md index fb87603b..00f53f1e 100644 --- a/docs/_github/preset_preview.md +++ b/docs/_github/preset_preview.md @@ -1,6 +1,7 @@ --- -![show](https://img.shields.io/endpoint?url=https://current-date.jsoneri.workers.dev/) +> [!Tip] +> 绿色包初始只会安装稳定版,升到开发版仅能通过 内置更新 或 [指定版本命令](https://cgs.101114105.xyz/deploy/quick-start.html#_4-%E6%9B%B4%E6%96%B0) 两种方式 [🚀快速上手(❗️新用户必读)](https://cgs.101114105.xyz/deploy/quick-start) | [❓常见问题](https://cgs.101114105.xyz/faq) | [⚡️github资源下载加速](https://github.akams.cn/) diff --git a/docs/config/index.md b/docs/config/index.md index 769753ab..b88bf813 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -163,6 +163,11 @@ maccy(macOS): [issue 搜索相关得知](https://github.com/p0deje/Maccy/issues/ 勾选并生效后,章节选择时会额外增加展示单行本栏和其他栏目 +### DiscordToken + +位于 `配置窗口 > 潘多拉之盒 > discord 旁边的编辑框` +分享相关,进 discord 的 welcome 频道 bot 点按钮获取,这是上传下载的凭证 + ## 其他 `yml` 字段 ::: info 此类字段没提供配置窗口便捷修改(或以后支持),不设时使用默认值 diff --git a/utils/config/__init__.py b/utils/config/__init__.py index 7488e673..b3438dfb 100644 --- a/utils/config/__init__.py +++ b/utils/config/__init__.py @@ -167,6 +167,7 @@ class Conf(BaseConf): active_workflow: str = '' skipDev: bool = False skipped_version: str = '' + discord_share_user_token: str = '' def __init__(self, path=None, iname=None): self.init_conf() diff --git a/utils/core.py b/utils/core.py index 9fbb1be1..cbf453c3 100644 --- a/utils/core.py +++ b/utils/core.py @@ -86,6 +86,7 @@ def __init__(self, taskid: str, title: str, tasks_count: int, title_url: str = N self.downloaded = [] self.meta_info = meta_info self.local_path = None + self.share_payload = None @property def display_title(self) -> str: diff --git a/utils/share/__init__.py b/utils/share/__init__.py new file mode 100644 index 00000000..ab35861a --- /dev/null +++ b/utils/share/__init__.py @@ -0,0 +1,14 @@ +from .discord_api import DiscordShareAPI, DiscordShareApiError, DiscordShareCooldownError, DiscordSharePayloadTooLargeError +from .preview_gen import build_cover_bytes, resolve_local_cover_path +from .serializer import deserialize_books, serialize_books + +__all__ = [ + "DiscordShareAPI", + "DiscordShareApiError", + "DiscordShareCooldownError", + "DiscordSharePayloadTooLargeError", + "build_cover_bytes", + "resolve_local_cover_path", + "deserialize_books", + "serialize_books", +] diff --git a/utils/share/discord_api.py b/utils/share/discord_api.py new file mode 100644 index 00000000..0c9fd8eb --- /dev/null +++ b/utils/share/discord_api.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json as _json + +import httpx + +from utils import get_httpx_verify + + +class DiscordShareApiError(RuntimeError): + pass + + +class DiscordShareCooldownError(DiscordShareApiError): + def __init__(self, hours_left: int): + self.hours_left = int(hours_left) + super().__init__(f"冷却中,还需 {self.hours_left} 小时") + + +class DiscordSharePayloadTooLargeError(DiscordShareApiError): + def __init__(self, size_bytes: int, *, limit_bytes: int): + self.size_bytes = int(size_bytes) + self.limit_bytes = int(limit_bytes) + size_mb = self.size_bytes / (1024 * 1024) + limit_mb = self.limit_bytes / (1024 * 1024) + super().__init__(f"文件过大({size_mb:.2f}MB > {limit_mb:.0f}MB)") + + +class DiscordShareAPI: + def __init__(self, api_url: str, user_token: str, *, timeout: float = 60.0, transport_retries: int = 2): + self.api_url = str(api_url or "").rstrip("/") + self.user_token = str(user_token or "").strip() + self.timeout = timeout + self.transport_retries = int(transport_retries) + + def _transport(self) -> httpx.AsyncHTTPTransport: + return httpx.AsyncHTTPTransport(retries=self.transport_retries, verify=get_httpx_verify()) + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=self.timeout, transport=self._transport(), trust_env=False) + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.user_token}"} + + async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: + try: + async with self._client() as client: + return await client.request(method, url, **kwargs) + except httpx.HTTPError as exc: + raise DiscordShareApiError(f"网络错误,请稍后重试: {exc}") from exc + + async def upload_share(self, *, payload_bytes: bytes, covers: list[tuple[str, bytes]], site: str, book_names: list[str]) -> str: + if not self.user_token: + raise DiscordShareApiError("discord_share_user_token 未配置") + if not covers: + raise DiscordShareApiError("covers 不能为空") + if len(covers) > 10: + raise DiscordShareApiError(f"covers 上限 10,当前 {len(covers)}") + if len(book_names) != len(covers): + raise DiscordShareApiError(f"book_names ({len(book_names)}) 与 covers ({len(covers)}) 数量不一致") + files = [("pkl_file", ("share.pkl", payload_bytes, "application/octet-stream"))] + for idx, (_name, cover_bytes) in enumerate(covers): + files.append((f"cover_{idx}", (f"cover_{idx}.jpg", cover_bytes, "image/jpeg"))) + data = { + "site": site, + "book_count": str(len(covers)), + "book_names": _json.dumps(list(book_names), ensure_ascii=False), + } + response = await self._request( + "POST", f"{self.api_url}/api/upload", headers=self._headers(), files=files, data=data, + ) + return self._parse_upload_response(response) + + async def download_share(self, share_id: str) -> bytes: + if not share_id: + raise DiscordShareApiError("shareID 不能为空") + response = await self._request( + "GET", f"{self.api_url}/api/download/{share_id}", headers=self._headers(), follow_redirects=True, timeout=30.0, + ) + if response.status_code == 404: + raise DiscordShareApiError("分享不存在或已删除") + if response.status_code >= 400: + raise DiscordShareApiError(self._extract_error(response)) + return response.content + + @staticmethod + def _extract_error(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + payload = {} + message = str(payload.get("error") or payload.get("message") or "").strip() + if response.status_code == 401: + return message or "user_token 无效,请重新在 Discord 获取" + if response.status_code == 413: + return message or "文件过大" + if response.status_code == 429: + hours_left = int(payload.get("hours_left") or 0) + return f"冷却中,还需 {hours_left} 小时" if hours_left > 0 else "冷却中" + return message or f"Discord share API error: HTTP {response.status_code}" + + def _parse_upload_response(self, response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + payload = {} + if response.status_code == 429: + raise DiscordShareCooldownError(int(payload.get("hours_left") or 0)) + if response.status_code >= 400: + raise DiscordShareApiError(self._extract_error(response)) + share_id = str(payload.get("share_id") or payload.get("message_id") or "").strip() + if not share_id: + raise DiscordShareApiError("share API 缺少 share_id") + return share_id diff --git a/utils/share/preview_gen.py b/utils/share/preview_gen.py new file mode 100644 index 00000000..a0a5f63b --- /dev/null +++ b/utils/share/preview_gen.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import io +import math +from pathlib import Path + +from PIL import Image + +_COVER_MAX_WIDTH = 512 +_COVER_MAX_HEIGHT = 768 +_JPEG_QUALITY = 85 +_COVER_EXTS = ("jpg", "jpeg", "png", "webp", "avif", "gif") + + +def iter_local_cover_candidates(book): + seen = set() + local_path = getattr(book, "local_path", None) + if local_path: + local_dir = Path(local_path) + page_count = int(getattr(book, "pages", 0) or 0) + digit_widths = {1, len(str(page_count or 1)), 2, 3} + for width in sorted(digit_widths, reverse=True): + stem = str(1).zfill(width) + for ext in _COVER_EXTS: + candidate = local_dir / f"{stem}.{ext}" + key = str(candidate) + if key in seen: + continue + seen.add(key) + yield candidate + for candidate in sorted(local_dir.iterdir()) if local_dir.exists() else (): + if not candidate.is_file() or candidate.suffix.lower().lstrip(".") not in _COVER_EXTS: + continue + key = str(candidate) + if key in seen: + continue + seen.add(key) + yield candidate + preview_path = getattr(book, "img_preview", None) + if isinstance(preview_path, str) and preview_path and not preview_path.startswith("http"): + candidate = Path(preview_path) + key = str(candidate) + if key not in seen: + yield candidate + + +def resolve_local_cover_path(book) -> Path | None: + for candidate in iter_local_cover_candidates(book): + try: + if candidate.exists(): + return candidate + except OSError: + continue + return None + + +def _open_cover(book) -> Image.Image | None: + candidate = resolve_local_cover_path(book) + if candidate is None: + return None + try: + return Image.open(candidate).convert("RGB") + except OSError: + return None + + +def _fit_cover(image: Image.Image) -> Image.Image: + width, height = image.size + if width <= 0 or height <= 0: + raise ValueError("cover image has invalid size") + scale = min(_COVER_MAX_WIDTH / width, _COVER_MAX_HEIGHT / height, 1.0) + if scale >= 1.0: + return image + target_size = (max(1, math.floor(width * scale)), max(1, math.floor(height * scale))) + return image.resize(target_size, Image.Resampling.LANCZOS) + + +def build_cover_bytes(book) -> bytes: + cover = _open_cover(book) + if cover is None: + raise FileNotFoundError(f"cover not found for book: {getattr(book, 'name', '?')}") + fitted = _fit_cover(cover) + output = io.BytesIO() + fitted.save(output, format="JPEG", quality=_JPEG_QUALITY, optimize=True) + return output.getvalue() diff --git a/utils/share/serializer.py b/utils/share/serializer.py new file mode 100644 index 00000000..5a06eb22 --- /dev/null +++ b/utils/share/serializer.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import io +import pickle +from copy import deepcopy + +from utils.website.info import ( + BookInfo, + Ero, + Episode, + HComicBookInfo, + HitomiBookInfo, + InfoMinix, + JmBookInfo, + JestfulBookInfo, + KbBookInfo, + MangabzBookInfo, + ManhuaguiBookInfo, + NhentaiBookInfo, + WnacgBookInfo, + EhBookInfo, +) + +_ALLOWED_CLASSES = { + "builtins": {"list", "dict", "str", "int", "float", "bool", "NoneType", "set", "tuple"}, + "utils.website.info": { + "BookInfo", + "Ero", + "Episode", + "InfoMinix", + "JmBookInfo", + "EhBookInfo", + "HitomiBookInfo", + "MangabzBookInfo", + "KbBookInfo", + "WnacgBookInfo", + "HComicBookInfo", + "NhentaiBookInfo", + "JestfulBookInfo", + "ManhuaguiBookInfo", + }, +} +_ALLOWED_BOOK_TYPES = ( + BookInfo, + Ero, + JmBookInfo, + EhBookInfo, + HitomiBookInfo, + MangabzBookInfo, + KbBookInfo, + WnacgBookInfo, + HComicBookInfo, + NhentaiBookInfo, + JestfulBookInfo, + ManhuaguiBookInfo, +) +_PAYLOAD_PREFIX = b"CGS_SHARE_V1\n" + + +class SafeUnpickler(pickle.Unpickler): + def find_class(self, module, name): + if module in _ALLOWED_CLASSES and name in _ALLOWED_CLASSES[module]: + return super().find_class(module, name) + raise pickle.UnpicklingError(f"Forbidden class: {module}.{name}") + + +def _normalize_books(books: list) -> list: + normalized = [] + for idx, book in enumerate(list(books or []), start=1): + if not isinstance(book, _ALLOWED_BOOK_TYPES): + raise TypeError(f"share books must be BookInfo-compatible, got {type(book).__name__}") + cloned = deepcopy(book) + cloned.idx = idx + episodes = list(getattr(cloned, "episodes", None) or []) + for ep_idx, episode in enumerate(episodes, start=1): + if not isinstance(episode, Episode): + raise TypeError(f"share episodes must be Episode, got {type(episode).__name__}") + episode.idx = ep_idx + episode.from_book = cloned + cloned.episodes = episodes or None + normalized.append(cloned) + return normalized + + +def serialize_books(books: list) -> bytes: + payload_books = _normalize_books(books) + body = pickle.dumps(payload_books, protocol=pickle.HIGHEST_PROTOCOL) + return _PAYLOAD_PREFIX + body + + +def deserialize_books(payload: bytes) -> list: + raw = bytes(payload) + if not raw.startswith(_PAYLOAD_PREFIX): + raise pickle.UnpicklingError("Unsupported share payload prefix") + body = raw[len(_PAYLOAD_PREFIX):] + result = SafeUnpickler(io.BytesIO(body)).load() + if not isinstance(result, list): + raise pickle.UnpicklingError(f"share payload must unpack to list, got {type(result).__name__}") + return _normalize_books(result) diff --git a/utils/website/site_runtime.py b/utils/website/site_runtime.py index 8738fb53..90ce1588 100644 --- a/utils/website/site_runtime.py +++ b/utils/website/site_runtime.py @@ -365,9 +365,4 @@ def peek_cached_domain(self) -> str | None: def _peek_cached_domain_for(provider_descriptor: ProviderDescriptor) -> str | None: if not issubclass(provider_descriptor.provider_cls, DomainUtils): return None - cache_path = temp_p.joinpath(f"{provider_descriptor.provider_name}_domain.txt") - if cache_path.exists(): - cached_text = cache_path.read_text(encoding="utf-8").strip() - if cached_text: - return cached_text - return None + return _normalize_domain_value(provider_descriptor.provider_cls.peek_cached_domain()) diff --git a/variables/__init__.py b/variables/__init__.py index 995ba96e..e9dee43b 100644 --- a/variables/__init__.py +++ b/variables/__init__.py @@ -104,3 +104,4 @@ def _label(s: Spider) -> str: 3: "https://repo.huaweicloud.com/repository/pypi/simple/", } CGS_DOC = "https://cgs.101114105.xyz" +CGS_DISCORD_SHARE_API = "https://cgs-share.101114105.xyz" From dac2d66a787d7754924fa969ae4fa367559e52b2 Mon Sep 17 00:00:00 2001 From: json Date: Sun, 17 May 2026 13:16:21 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20cgs-share=20=E2=85=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GUI/gui.py | 13 +--- GUI/manager/task_progress.py | 7 +- GUI/src/material_ct.py | 133 +++++++++++++++++++++++++---------- docs/script/index.md | 25 ++++--- utils/share/discord_api.py | 10 --- utils/share/serializer.py | 30 +++++++- utils/website/info.py | 7 +- 7 files changed, 147 insertions(+), 78 deletions(-) diff --git a/GUI/gui.py b/GUI/gui.py index e967b616..a8540a6d 100644 --- a/GUI/gui.py +++ b/GUI/gui.py @@ -539,21 +539,10 @@ def start_and_search(self, keyword=None, site_index=None): kw = self.searchinput.text().strip() if kw.startswith("dc:"): share_id = kw[3:].strip() - if not share_id: - InfoBar.info( - title='', content='请输入 dc:shareID', isClosable=True, - position=InfoBarPosition.BOTTOM, duration=2000, parent=self.textBrowser - ) - return self.shares.download(share_id) return if not kw: - InfoBar.info( - title='', content='先输入搜索词吧', isClosable=True, - position=InfoBarPosition.BOTTOM, duration=2000, - parent=self.textBrowser - ) - return + return InfoBar.info(title='', content='先输入搜索词吧', isClosable=True, position=InfoBarPosition.BOTTOM, duration=2000, parent=self.textBrowser) site = self.chooseBox.currentIndex() if site not in SPIDERS or not getattr(self.preview_mgr, "worker", None): self.refresh_lifecycle_state() diff --git a/GUI/manager/task_progress.py b/GUI/manager/task_progress.py index 05bdeba0..3ff89ca9 100644 --- a/GUI/manager/task_progress.py +++ b/GUI/manager/task_progress.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt, QEvent, QObject, QRect, QSize, QUrl from PySide6.QtWidgets import QWidget, QLabel, QFrame -from PySide6.QtGui import QGuiApplication, QPixmap, QDesktopServices +from PySide6.QtGui import QGuiApplication, QPixmap, QDesktopServices, QIcon from qfluentwidgets import ( ProgressBar, VBoxLayout, PrimaryToolButton, TransparentToolButton, TransparentToggleToolButton, InfoBar, InfoBarPosition, @@ -105,14 +105,15 @@ def __init__(self, parent: QWidget, progress: TaskProgress, gui): for attr, icon, btn_cls, callback in ( ("folder_btn", FIF.FOLDER, TransparentToolButton, self._open_task_folder), ("link_btn", FIF.LINK, TransparentToolButton, self._open_task_link), - ("share_btn", FIF.SHARE, TransparentToggleToolButton, self._share_task), + ("share_btn", QIcon(':/main/add.svg'), TransparentToggleToolButton, self._share_task), ): btn_parent = self if attr == "share_btn" else self.cover_label btn = btn_cls(icon, btn_parent) btn.setCursor(Qt.PointingHandCursor) btn.setFixedSize(self.ACTION_BUTTON_SIZE, self.ACTION_BUTTON_SIZE) btn.setIconSize(QSize(12, 12)) - btn.setStyleSheet("background: rgba(20, 20, 20, 0.4);") + if attr != "share_btn": + btn.setStyleSheet("background: rgba(20, 20, 20, 0.4);") btn.clicked.connect(callback) setattr(self, attr, btn) self.page_badge = QLabel(self.cover_label) diff --git a/GUI/src/material_ct.py b/GUI/src/material_ct.py index 5a807177..0cd7d3e1 100644 --- a/GUI/src/material_ct.py +++ b/GUI/src/material_ct.py @@ -7790,6 +7790,59 @@ 5 1.014 5.497 1.\ 804 8.182z\x22 />\x0d\x0a\ \ +\x00\x00\x03-\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2224\ +\x22 height=\x2224\x22 vi\ +ewBox=\x220 0 24 24\ +\x22>\ \x00\x00\x01\xf3\ <\ svg xmlns=\x22http:\ @@ -18853,6 +18906,10 @@ \x00p\ \x00u\x00b\x00l\x00i\x00s\x00h\x00.\x00s\x00v\x00g\ \x00\x07\ +\x07\xa7Z\x07\ +\x00a\ +\x00d\x00d\x00.\x00s\x00v\x00g\ +\x00\x07\ \x08lZ\x07\ \x00a\ \x00p\x00i\x00.\x00s\x00v\x00g\ @@ -18994,9 +19051,9 @@ qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x0e\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x01\x1e\x00\x02\x00\x00\x00\x02\x00\x00\x003\ +\x00\x00\x01\x1e\x00\x02\x00\x00\x00\x02\x00\x00\x004\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\xf2\x00\x02\x00\x00\x00\x02\x00\x00\x001\ +\x00\x00\x00\xf2\x00\x02\x00\x00\x00\x03\x00\x00\x001\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\xd2\x00\x02\x00\x00\x00\x03\x00\x00\x00.\ \x00\x00\x00\x00\x00\x00\x00\x00\ @@ -19013,7 +19070,7 @@ \x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x00\xb4q\ \x00\x00\x01\x9c\xb4c\x9e\x83\ \x00\x00\x01*\x00\x00\x00\x00\x00\x01\x00\x01n\xe1\ -\x00\x00\x01\x9c\xb4\x5cD{\ +\x00\x00\x01\x9e5\x07\xad\x04\ \x00\x00\x00j\x00\x00\x00\x00\x00\x01\x00\x00\xd8\x99\ \x00\x00\x01\x9c\xae\xdcb#\ \x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x01\x1c\xbe\ @@ -19022,76 +19079,78 @@ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00.\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x9c\xb4b^\xa4\ -\x00\x00\x03B\x00\x00\x00\x00\x00\x01\x00\x02\x90W\ +\x00\x00\x03V\x00\x00\x00\x00\x00\x01\x00\x02\x93\x88\ \x00\x00\x01\x9c\x0eM\xb5\xee\ -\x00\x00\x03d\x00\x00\x00\x00\x00\x01\x00\x02\x91^\ +\x00\x00\x03x\x00\x00\x00\x00\x00\x01\x00\x02\x94\x8f\ \x00\x00\x01\x9c\x13(R,\ -\x00\x00\x03$\x00\x00\x00\x00\x00\x01\x00\x02\x8fD\ +\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x02\x92u\ \x00\x00\x01\x9c\x0eM\xdf\xb2\ -\x00\x00\x03\x02\x00\x00\x00\x00\x00\x01\x00\x02\x8c6\ +\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x02\x8fg\ \x00\x00\x01\x9c2T\xc2\xca\ -\x00\x00\x01\xc0\x00\x00\x00\x00\x00\x01\x00\x01\xe7\xec\ +\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x01\xeb\x1d\ \x00\x00\x01\x9d\x85\xcf\xea\xe5\ -\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x01\xe7\x03\ +\x00\x00\x01\xba\x00\x00\x00\x00\x00\x01\x00\x01\xea4\ \x00\x00\x01\x9d\x0c\x02C\x1e\ -\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\x0e\xec\ +\x00\x00\x02r\x00\x00\x00\x00\x00\x01\x00\x02\x12\x1d\ \x00\x00\x01\x9d\xaf\xa2\x8c\xb6\ -\x00\x00\x01\xda\x00\x00\x00\x00\x00\x01\x00\x01\xea\x06\ +\x00\x00\x01\xee\x00\x00\x00\x00\x00\x01\x00\x01\xed7\ \x00\x00\x01\x9d\x0e\xee[\x93\ -\x00\x00\x01\xf4\x00\x00\x00\x00\x00\x01\x00\x01\xfaD\ +\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x01\xfdu\ \x00\x00\x01\x9d\x0c\x01]\xbe\ -\x00\x00\x01\x92\x00\x00\x00\x00\x00\x01\x00\x01\xe5\x0c\ +\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x01\xe8=\ \x00\x00\x01\x9d\xb0\x9fAZ\ -\x00\x00\x02J\x00\x00\x00\x00\x00\x01\x00\x02\x00z\ +\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\x03\xab\ \x00\x00\x01\x9d\xb67$A\ -\x00\x00\x02\x10\x00\x00\x00\x00\x00\x01\x00\x01\xfb*\ +\x00\x00\x02$\x00\x00\x00\x00\x00\x01\x00\x01\xfe[\ \x00\x00\x01\x9d\xaf\x9f\x9d\x0d\ -\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x02\x11\x1e\ +\x00\x00\x02\x90\x00\x00\x00\x00\x00\x01\x00\x02\x14O\ \x00\x00\x01\x9d\x0c\x09\xea!\ -\x00\x00\x02*\x00\x00\x00\x00\x00\x01\x00\x01\xfe\xf9\ +\x00\x00\x02>\x00\x00\x00\x00\x00\x01\x00\x02\x02*\ \x00\x00\x01\x9d\xec\xa2\xdb\xb7\ -\x00\x00\x03\x82\x00\x00\x00\x00\x00\x01\x00\x02\x929\ +\x00\x00\x03\x96\x00\x00\x00\x00\x00\x01\x00\x02\x95j\ \x00\x00\x01\x9d\xe2\xe59\xab\ -\x00\x00\x03\x96\x00\x00\x00\x00\x00\x01\x00\x02\x96\x89\ +\x00\x00\x03\xaa\x00\x00\x00\x00\x00\x01\x00\x02\x99\xba\ \x00\x00\x01\x96\xe8k\x98/\ -\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x03\x84\x82\ +\x00\x00\x04 \x00\x00\x00\x00\x00\x01\x00\x03\x87\xb3\ \x00\x00\x01\x96\xe86!\x98\ -\x00\x00\x03\xb2\x00\x00\x00\x00\x00\x01\x00\x02\x99\xba\ +\x00\x00\x03\xc6\x00\x00\x00\x00\x00\x01\x00\x02\x9c\xeb\ \x00\x00\x01\x99\x7f\xe7F\xc3\ -\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x03\x0fX\ +\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x01\x00\x03\x12\x89\ \x00\x00\x01\x9dh\x81\x90\x5c\ -\x00\x00\x04\x1e\x00\x00\x00\x00\x00\x01\x00\x03\x87\x10\ +\x00\x00\x042\x00\x00\x00\x00\x00\x01\x00\x03\x8aA\ \x00\x00\x01\x9c\xb4f.7\ -\x00\x00\x03\xf0\x00\x00\x00\x00\x00\x01\x00\x03\x10L\ +\x00\x00\x04\x04\x00\x00\x00\x00\x00\x01\x00\x03\x13}\ \x00\x00\x01\x99\x7f\xc9\xd1=\ -\x00\x00\x04\xd0\x00\x00\x00\x00\x00\x01\x00\x04\x19\xbf\ +\x00\x00\x04\xe4\x00\x00\x00\x00\x00\x01\x00\x04\x1c\xf0\ \x00\x00\x01\x9d{\xbd\xcc\xa5\ -\x00\x00\x04\x94\x00\x00\x00\x00\x00\x01\x00\x03\xf55\ +\x00\x00\x04\xa8\x00\x00\x00\x00\x00\x01\x00\x03\xf8f\ \x00\x00\x01\x97U\x5c\x0d\xb0\ -\x00\x00\x05(\x00\x00\x00\x00\x00\x01\x00\x04b\x01\ +\x00\x00\x05<\x00\x00\x00\x00\x00\x01\x00\x04e2\ \x00\x00\x01\x9d\xd8\xd7\xf5\xc0\ -\x00\x00\x05:\x00\x00\x00\x00\x00\x01\x00\x04e\xb8\ +\x00\x00\x05N\x00\x00\x00\x00\x00\x01\x00\x04h\xe9\ \x00\x00\x01\x9c\x90\x8a&\xdc\ -\x00\x00\x04\xb8\x00\x00\x00\x00\x00\x01\x00\x04\x185\ +\x00\x00\x04\xcc\x00\x00\x00\x00\x00\x01\x00\x04\x1bf\ \x00\x00\x01\x97KW\xb9p\ -\x00\x00\x05\x0a\x00\x00\x00\x00\x00\x01\x00\x04>O\ +\x00\x00\x05\x1e\x00\x00\x00\x00\x00\x01\x00\x04A\x80\ \x00\x00\x01\x9cMr\x8c\xc3\ -\x00\x00\x04b\x00\x00\x00\x00\x00\x01\x00\x03\xda3\ +\x00\x00\x04v\x00\x00\x00\x00\x00\x01\x00\x03\xddd\ \x00\x00\x01\x9c\x90\x88\xa8\x03\ -\x00\x00\x04\xe6\x00\x00\x00\x00\x00\x01\x00\x04(\xda\ +\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x04,\x0b\ \x00\x00\x01\x9d{\xbd\xf4\xff\ -\x00\x00\x04>\x00\x00\x00\x00\x00\x01\x00\x03\xd8V\ +\x00\x00\x04R\x00\x00\x00\x00\x00\x01\x00\x03\xdb\x87\ \x00\x00\x01\x97KW\xd0\xc3\ -\x00\x00\x04~\x00\x00\x00\x00\x00\x01\x00\x03\xe71\ +\x00\x00\x04\x92\x00\x00\x00\x00\x00\x01\x00\x03\xeab\ \x00\x00\x01\x9d{\xbb\xc7+\ -\x00\x00\x02\xe2\x00\x00\x00\x00\x00\x01\x00\x02{9\ +\x00\x00\x02\xf6\x00\x00\x00\x00\x00\x01\x00\x02~j\ \x00\x00\x01\x93\xa1\x9f\xe5\xf8\ -\x00\x00\x02\xba\x00\x00\x00\x00\x00\x01\x00\x02Gc\ +\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x02J\x94\ \x00\x00\x01\x93\xa1\xca\xf1T\ -\x00\x00\x02\x9a\x00\x00\x00\x00\x00\x01\x00\x02\x13\x80\ +\x00\x00\x02\xae\x00\x00\x00\x00\x00\x01\x00\x02\x16\xb1\ \x00\x00\x01\x93\xa1\xcb\xcdO\ \x00\x00\x01v\x00\x00\x00\x00\x00\x01\x00\x01\xe1a\ \x00\x00\x01\x9d>\xdc\xb4X\ +\x00\x00\x01\x92\x00\x00\x00\x00\x00\x01\x00\x01\xe5\x0c\ +\x00\x00\x01\x9e5\x08O\x9c\ \x00\x00\x01\x5c\x00\x01\x00\x00\x00\x01\x00\x01\xde\xf6\ \x00\x00\x01\x9d\xe2\xe5\x98F\ \x00\x00\x01D\x00\x00\x00\x00\x00\x01\x00\x01\x9c\xf4\ diff --git a/docs/script/index.md b/docs/script/index.md index 5fd34b5d..11a20cce 100644 --- a/docs/script/index.md +++ b/docs/script/index.md @@ -9,18 +9,19 @@ kemono / danbooru / cbg / saucenao 任务模块:[Redis-windows](https://github.com/redis-windows/redis-windows/releases) | mac:`brew install redis` 下载引擎:[Motrix](https://github.com/agalwood/Motrix/releases) ::: -::: tip 源码使用 `uv` 安装脚本集依赖(GUI下的程序内站点切到 script 时已自动化处理了) -> [!info] 仅 2.10.0-beta 前需要,后续强制统一安装所有依赖 -```bash -uv tool install ComicGUISpider[script] --force --index-url https://pypi.tuna.tsinghua.edu.cn/simple -``` -⚠️ win绿色包自动安装依赖失败时则用以下命令 -(基于`_pystand_static.int` 的 `version` 大于等于 `v2`) -```cmd -.\CGS.exe -v 2.9.11 -s -i https://pypi.tuna.tsinghua.edu.cn/simple -``` +::: tip 指引 +Redis-windows: 下载 *-cygwin-with-Service.zip +查看 [文档](https://github.com/redis-windows/redis-windows/blob/main/README.zh_CN.md) 安装为 Windows 服务 + +Motrix 在进入Script 前时保持启动即可 ::: +| 前置矩阵 | redis-service | Motrix | +| --- | :---: | :---: | +| Kemono | ⭕️ | ⭕️ | +| Danbooru | ⭕️
实际不需要,后续将解耦脱离 | ⭕️ | +| Cbg | ➖ | ➖ | + ::: details 脚本目录树: `script`目录 (非 GUI 相关) ```shell utils @@ -42,9 +43,7 @@ utils ## 3. Cbg (CornerBackground) -> [!Tip] 无需通用前置 - -入口为 rvTool 橙色按钮 Cbg +快捷入口为 rvTool 橙色按钮 Cbg ::: details 功能为油猴脚本,让立绘资源在浏览器右下角常驻展示(当前仅本地资源,火狐暂不支持 ¹) > [!Tip] 需要进扩展管理油猴权限设 `允许访问文件网址` (火狐没有所以不支持) diff --git a/utils/share/discord_api.py b/utils/share/discord_api.py index 0c9fd8eb..1e83a452 100644 --- a/utils/share/discord_api.py +++ b/utils/share/discord_api.py @@ -50,14 +50,6 @@ async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: raise DiscordShareApiError(f"网络错误,请稍后重试: {exc}") from exc async def upload_share(self, *, payload_bytes: bytes, covers: list[tuple[str, bytes]], site: str, book_names: list[str]) -> str: - if not self.user_token: - raise DiscordShareApiError("discord_share_user_token 未配置") - if not covers: - raise DiscordShareApiError("covers 不能为空") - if len(covers) > 10: - raise DiscordShareApiError(f"covers 上限 10,当前 {len(covers)}") - if len(book_names) != len(covers): - raise DiscordShareApiError(f"book_names ({len(book_names)}) 与 covers ({len(covers)}) 数量不一致") files = [("pkl_file", ("share.pkl", payload_bytes, "application/octet-stream"))] for idx, (_name, cover_bytes) in enumerate(covers): files.append((f"cover_{idx}", (f"cover_{idx}.jpg", cover_bytes, "image/jpeg"))) @@ -72,8 +64,6 @@ async def upload_share(self, *, payload_bytes: bytes, covers: list[tuple[str, by return self._parse_upload_response(response) async def download_share(self, share_id: str) -> bytes: - if not share_id: - raise DiscordShareApiError("shareID 不能为空") response = await self._request( "GET", f"{self.api_url}/api/download/{share_id}", headers=self._headers(), follow_redirects=True, timeout=30.0, ) diff --git a/utils/share/serializer.py b/utils/share/serializer.py index 5a06eb22..87a42c3f 100644 --- a/utils/share/serializer.py +++ b/utils/share/serializer.py @@ -57,11 +57,35 @@ _PAYLOAD_PREFIX = b"CGS_SHARE_V1\n" +def _collect_declared_fields(cls) -> set: + fields = set() + for klass in cls.__mro__: + if klass is object: + continue + fields.update(getattr(klass, "__annotations__", {}).keys()) + return fields + + +def _strip_undeclared(obj) -> None: + declared = _collect_declared_fields(type(obj)) + for key in list(vars(obj).keys()): + if key not in declared: + delattr(obj, key) + + +class _ForeignObj: + def __init__(self, *args, **kwargs): + pass + + def __setstate__(self, state): + pass + + class SafeUnpickler(pickle.Unpickler): def find_class(self, module, name): if module in _ALLOWED_CLASSES and name in _ALLOWED_CLASSES[module]: return super().find_class(module, name) - raise pickle.UnpicklingError(f"Forbidden class: {module}.{name}") + return _ForeignObj def _normalize_books(books: list) -> list: @@ -70,11 +94,13 @@ def _normalize_books(books: list) -> list: if not isinstance(book, _ALLOWED_BOOK_TYPES): raise TypeError(f"share books must be BookInfo-compatible, got {type(book).__name__}") cloned = deepcopy(book) - cloned.idx = idx episodes = list(getattr(cloned, "episodes", None) or []) + _strip_undeclared(cloned) + cloned.idx = idx for ep_idx, episode in enumerate(episodes, start=1): if not isinstance(episode, Episode): raise TypeError(f"share episodes must be Episode, got {type(episode).__name__}") + _strip_undeclared(episode) episode.idx = ep_idx episode.from_book = cloned cloned.episodes = episodes or None diff --git a/utils/website/info.py b/utils/website/info.py index ea5aedcf..9d6be604 100644 --- a/utils/website/info.py +++ b/utils/website/info.py @@ -55,9 +55,14 @@ def to_sql(self) -> dict: class Manga(BookInfo): episodes: list = [] - img_preview: str = None + img_preview: str = None latest_sec: str = None render_keys: list = [] + popular: t.Any = None + datetime_updated: str = None + last_chapter_name: str = None + other_names: list = None + other_name_raw: str = None @property def say(self): From 8e82b9c80548e4028c16683a1bc527084443a975 Mon Sep 17 00:00:00 2001 From: json Date: Mon, 18 May 2026 03:37:58 +0800 Subject: [PATCH 04/11] feat: submit loading(curr manga) --- GUI/browser_window.py | 17 ++++++++++++++++- GUI/gui.py | 7 +++---- GUI/manager/preview/manga.py | 9 +++++++++ assets/res/locale/en_US.yml | 1 - assets/res/locale/zh_CN.yml | 3 +-- docs/changelog/history.md | 6 +++++- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/GUI/browser_window.py b/GUI/browser_window.py index a06fbb11..ef7c98a4 100644 --- a/GUI/browser_window.py +++ b/GUI/browser_window.py @@ -8,7 +8,7 @@ from PySide6.QtCore import Qt, QUrl, QEvent, QSize, Signal, QLoggingCategory from PySide6.QtGui import QIcon, QKeySequence, QShortcut from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineSettings -from qfluentwidgets import InfoBar, InfoBarPosition, FluentIcon as FIF, ToolTipFilter, ToolTipPosition +from qfluentwidgets import InfoBar, InfoBarPosition, FluentIcon as FIF, IndeterminateProgressBar, ToolTipFilter, ToolTipPosition from qframelesswindow import FramelessMainWindow from qframelesswindow.webengine import FramelessWebEngineView from qframelesswindow.utils import startSystemMove @@ -193,6 +193,11 @@ def setupUi(self, _window): self.closeBtn.setIconSize(QSize(20, 20)) self.closeBtn.setIcon(QIcon(':/close.svg')) + self.submitLoadingBar = IndeterminateProgressBar(self.groupBox, start=False) + self.submitLoadingBar.setFixedWidth(120) + self.submitLoadingBar.hide() + self.horizontalLayout_2.insertWidget(self.horizontalLayout_2.count() - 3, self.submitLoadingBar) + self.homeBtn.clicked.connect(self.load_home) self.backBtn.clicked.connect(self.view.back) self.forwardBtn.clicked.connect(self.view.forward) @@ -425,6 +430,16 @@ def show_task_added_toast(self, title: str): js_code = f"window.showTaskAddedToast && window.showTaskAddedToast({json.dumps(title)});" self.page_runtime.run_js(js_code) + def start_submit_loading(self): + self.submitLoadingBar.show() + if not self.submitLoadingBar.isStarted(): + self.submitLoadingBar.start() + + def stop_submit_loading(self): + if self.submitLoadingBar.isStarted(): + self.submitLoadingBar.stop() + self.submitLoadingBar.hide() + def closeEvent(self, event): self.page_runtime.shutdown() self.window_mode.shutdown() diff --git a/GUI/gui.py b/GUI/gui.py index a8540a6d..bba25384 100644 --- a/GUI/gui.py +++ b/GUI/gui.py @@ -274,10 +274,9 @@ def chooseBox_changed_tips(self, index): self.say(font_color(res.EHentai.GUIDE, cls='theme-highlight')) case _: if self.gui_site_runtime is not None: - self.say( - font_color(getattr(self.res, f"{self.gui_site_runtime.name}_desc", ""), cls='theme-highlight'), - ignore_http=True, - ) + desc = getattr(self.res, f"{self.gui_site_runtime.name}_desc", None) + if isinstance(desc, str) and desc: + self.say(font_color(desc, cls='theme-highlight'), ignore_http=True) if index in Spider.mangas(): self.say(font_color(self.res.manga_fav_tip, cls='theme-tip')) diff --git a/GUI/manager/preview/manga.py b/GUI/manager/preview/manga.py index 0fa7d02a..4fedb892 100644 --- a/GUI/manager/preview/manga.py +++ b/GUI/manager/preview/manga.py @@ -377,12 +377,14 @@ def on_pages_done(self, generation, book_key, episodes): if generation != self.mgr._generation: if not self._inflight_pages: self.mgr.send_command("preview.scan.hide", {}) + self._stop_submit_loading() return book, selected_eps = pending book.episodes = list(selected_eps) self.gui.sel_mgr.submit_decision("EP", book) if not self._inflight_pages: self.mgr.send_command("preview.scan.hide", {}) + self._stop_submit_loading() def on_pages_error(self, generation, book_key, error): self._inflight_pages.pop(book_key, None) @@ -390,6 +392,7 @@ def on_pages_error(self, generation, book_key, error): self.mgr.send_command("manga.episodes.error", {"bookKey": str(book_key), "code": "pages_fetch_failed"}) if not self._inflight_pages: self.mgr.send_command("preview.scan.hide", {}) + self._stop_submit_loading() # ------------------------------------------------------------------ # Selection / ensure @@ -474,4 +477,10 @@ def submit_page_selections(self): self._submit_payload(self._current_submit_payload()) def _handle_submit_request(self): + self.gui.BrowserWindow.start_submit_loading() self._submit_payload(self._current_submit_payload()) + if not self._inflight_pages: + self._stop_submit_loading() + + def _stop_submit_loading(self): + self.gui.BrowserWindow.stop_submit_loading() diff --git a/assets/res/locale/en_US.yml b/assets/res/locale/en_US.yml index a501f853..94150c60 100644 --- a/assets/res/locale/en_US.yml +++ b/assets/res/locale/en_US.yml @@ -18,7 +18,6 @@ GUI: jm_desc: "Supports multiple book IDs (decimal numbers), e.g. `123456,654321,114514` (comma-separated)
[Clipboard function: Avoid copying `18comic.vip` domain due to 5-second shield - direct ID input recommended]" wnacg_desc: "wnacg CN source may be slow/unstable. Common network errors [Errno 11001 10054 10060]/`ReadTimeout`:
Restart usually resolves. If persistent after multiple attempts, contact group/submit issue." mangabz_desc: "Māngabz uses iPhone web version. Note: Volumes/chapters both use numeric labels (e.g. Vol.1 and Ch.1 both labeled '1') - identify via adjacent chapters." - jestful_desc: "jestful loads chapter list and page images through dynamic JS endpoints. Search by title keyword first, then verify chapter numbering before download." hitomi_desc: "Except presets, use `hitomi-tools` for input📹References
1. Avoid using search-keyword 2. Hitomi does not support mappings" h_comic_desc: "h-comic is a frontend-rendered site. Prefer short keywords (core title words). If search returns empty, retry with synonyms." Script: diff --git a/assets/res/locale/zh_CN.yml b/assets/res/locale/zh_CN.yml index 674663c9..1a6c3486 100644 --- a/assets/res/locale/zh_CN.yml +++ b/assets/res/locale/zh_CN.yml @@ -18,8 +18,7 @@ GUI: jm_desc: "支持多车号输入,例如`123456,654321,114514`(逗号分隔)
支持章节下载,点击下方区域卡片即可
设置cookies能解锁游客隐藏,解决无效车号等,但CGS暂不会自动更新cookie,按需时粘贴curl用" wnacg_desc: "wancg 国内源偶尔会很慢抽风,报错的优先排查日志
网络问题重启重试几次后还是一直出现同一种错误的话 加群反映/提issue
蓝色的`域名管理`按钮可换域名" mangabz_desc: "Māngabz 使用源为iphone网页版,逆天章节只有数字,例如第一卷和第一话都是1,需要根据相邻章节自己鉴别" - jestful_desc: "jestful 章节列表与页图列表均由站点 JS 动态加载;建议优先使用作品名关键词搜索并按章节号核对选话" - hitomi_desc: "除预设外,输入请使用 hitomi-tools ,📹参考用法
1.不建议使用搜索,你的关键字极大可能无效 2.hitomi无法使用映射
【国内Tip】不用代理也能跑哦( ̄﹃ ̄)震惊吧,不过预览时访问链接涉及官网域名的就不行了" + hitomi_desc: "除预设外,输入请使用 hitomi-tools ,📹参考用法
1.不建议使用搜索,你的关键字极大可能无效
2.hitomi无法使用映射
【国内Tip】不用代理也能跑哦( ̄﹃ ̄)震惊吧,不过预览时访问链接涉及官网域名的就不行了" h_comic_desc: "h-comic 为前端渲染站,建议优先使用简短关键词(如作品名核心词)进行搜索;搜索结果为空时可换同义词重试" Script: site_name: "Script" diff --git a/docs/changelog/history.md b/docs/changelog/history.md index 92786f0a..3829041d 100644 --- a/docs/changelog/history.md +++ b/docs/changelog/history.md @@ -1,15 +1,19 @@ # 🕑 更新历史 -## `v2.10.1-beta` +## `v2.10.1-beta.2` ### 🎁 Features ++ 🌐支持 `dm5` ++ discord 分享模块 + 🌐支持 `nhentai` + 🌐支持 `manhuagui` (漫画柜) + Danbooru 支持 视频播放下载,zip下载 ### 🐞 Fix/Upd ++ 基于`dm5`章节提交至下载时长不定,增加提交反馈 ++ 站点状态增加三大宽带运营商维度 + 重新描述被忽视的`映射`能力,[详见文档](https://cgs.101114105.xyz//config/#%E6%98%A0%E5%B0%84-custom-map) + jestful 序号调整为 9 , 关注预设, jestful 预设词 `更新` 失效需要改为 `首页` + Danbooru 优化性能,多个细节体验优化(分页标识,视频标识,已收藏标识等) From 7dfe8d998923a823cc3073f4cc83324c6af205bc Mon Sep 17 00:00:00 2001 From: json Date: Thu, 14 May 2026 13:35:54 +0800 Subject: [PATCH 05/11] feat: add dm5 init by ga[no pass] --- ComicSpider/pipelines.py | 8 +- ComicSpider/spiders/dm5.py | 67 +++ utils/website/info.py | 4 + utils/website/ins.py | 4 +- utils/website/providers/__init__.py | 1 + utils/website/providers/dm5.py | 669 ++++++++++++++++++++++++++++ variables/__init__.py | 7 +- 7 files changed, 756 insertions(+), 4 deletions(-) create mode 100644 ComicSpider/spiders/dm5.py create mode 100644 utils/website/providers/dm5.py diff --git a/ComicSpider/pipelines.py b/ComicSpider/pipelines.py index fd00cdf8..46ece084 100644 --- a/ComicSpider/pipelines.py +++ b/ComicSpider/pipelines.py @@ -65,7 +65,13 @@ def get_media_requests(self, item, info): raise TypeError(f"{type(spider).__name__}.image_request_meta() must return dict or None") else: meta = dict(meta) - requests.append(Request(url,callback=NO_CALLBACK,headers=dict(headers),meta=meta)) + request_headers = dict(headers) + extra_headers = meta.get("headers") if isinstance(meta, dict) else None + if extra_headers is not None: + if not isinstance(extra_headers, dict): + raise TypeError(f"{type(spider).__name__}.image_request_meta()['headers'] must return dict when provided") + request_headers.update(dict(extra_headers)) + requests.append(Request(url,callback=NO_CALLBACK,headers=request_headers,meta=meta)) return requests # 图片存储前调用 diff --git a/ComicSpider/spiders/dm5.py b/ComicSpider/spiders/dm5.py new file mode 100644 index 00000000..b8fc7147 --- /dev/null +++ b/ComicSpider/spiders/dm5.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import scrapy + +from utils.website import Dm5Utils +from .basecomicspider import BaseComicSpider, ComicspiderItem + + +class Dm5Spider(BaseComicSpider): + name = "dm5" + image_ua = Dm5Utils.image_ua + custom_settings = { + "DOWNLOADER_MIDDLEWARES": { + "ComicSpider.middlewares.RefererMiddleware": 10, + "ComicSpider.middlewares.FakeMiddleware": 30, + } + } + + def _build_episode_items(self, ep, page_urls, *, chapter_referer): + book = ep.from_book + uid, u_md5 = ep.id_and_md5() + group_infos = {"title": book.name, "section": ep.name, "uuid": uid, "uuid_md5": u_md5} + ep.pages = len(page_urls) + self.set_task(ep) + if not hasattr(self, "_chapter_referers"): + self._chapter_referers = {} + if not hasattr(self, "_image_request_headers"): + self._image_request_headers = {} + self._chapter_referers[u_md5] = chapter_referer + self._image_request_headers[u_md5] = dict(getattr(ep, "dm5_image_headers", {}) or {}) + for page, image_url in enumerate(page_urls, start=1): + item = ComicspiderItem() + item.update(**group_infos) + item["page"] = page + item["image_urls"] = [image_url] + if self.job_context: + self.job_context.total += 1 + self.total += 1 + yield item + + def _yield_episode_items(self, ep, page_urls, *, chapter_referer): + for item in self._build_episode_items(ep, page_urls, chapter_referer=chapter_referer): + yield scrapy.Request( + url=f'https://fakefakefa.com/{item["image_urls"][0]}', + callback=self.process_item, + meta={'item': item, 'referer': chapter_referer}, + dont_filter=True, + ) + self._emit_process("fin") + + def _process_episode(self, ep): + page_urls = list(getattr(ep, "page_urls", None) or []) + chapter_referer = getattr(ep, "chapter_referer", None) or ep.url + if not page_urls or not chapter_referer: + missing = "page_urls" if not page_urls else "chapter_referer" + raise ValueError(f"dm5 episode requires {missing}: {ep!r}") + yield from self._yield_episode_items(ep, page_urls, chapter_referer=chapter_referer) + + def image_request_meta(self, *, url, item): + uuid_md5 = item.get("uuid_md5") + referer = getattr(self, "_chapter_referers", {}).get(uuid_md5) + headers = dict(getattr(self, "_image_request_headers", {}).get(uuid_md5) or {}) + if referer and "Referer" not in headers and "referer" not in headers: + headers["Referer"] = referer + return {"referer": referer, "headers": headers} if headers or referer else {} + + def process_item(self, response): + yield response.meta["item"] diff --git a/utils/website/info.py b/utils/website/info.py index 9d6be604..e020ee1c 100644 --- a/utils/website/info.py +++ b/utils/website/info.py @@ -151,6 +151,10 @@ class JestfulBookInfo(Manga): class ManhuaguiBookInfo(Manga): source = "manhuagui" + +class Dm5BookInfo(Manga): + source = "dm5" + # --- class JmBookInfo(Ero): diff --git a/utils/website/ins.py b/utils/website/ins.py index 3d1c1fe0..cc32281d 100644 --- a/utils/website/ins.py +++ b/utils/website/ins.py @@ -14,10 +14,10 @@ def _build_provider_map(): Spider.MANGA_COPY: KaobeiUtils, Spider.JM: JmUtils, Spider.WNACG: WnacgUtils, Spider.EHENTAI: EHentaiKits, Spider.MANGABZ: MangabzUtils, Spider.HITOMI: HitomiUtils, Spider.H_COMIC: HComicUtils, Spider.NHENTAI: NhentaiUtils, Spider.JESTFUL: JestfulUtils, - Spider.MANHUAGUI: ManhuaguiUtils, + Spider.MANHUAGUI: ManhuaguiUtils, Spider.DM5: Dm5Utils, } _PROVIDER_ALIASES = { - Spider.MANGA_COPY: ("kaobei",), Spider.JESTFUL: ("jf",), + Spider.MANGA_COPY: ("kaobei",), Spider.JESTFUL: ("jf",), Spider.DM5: ("dm",), } binding_map = {} for spider in sorted(Spider, key=int): diff --git a/utils/website/providers/__init__.py b/utils/website/providers/__init__.py index a1b53014..f8af493f 100644 --- a/utils/website/providers/__init__.py +++ b/utils/website/providers/__init__.py @@ -6,6 +6,7 @@ from .mangabz import * from .jestful import * from .manhuagui import * +from .dm5 import * from ..hitomi import * from .hcomic import * from ..nhentai import * diff --git a/utils/website/providers/dm5.py b/utils/website/providers/dm5.py new file mode 100644 index 00000000..822803f8 --- /dev/null +++ b/utils/website/providers/dm5.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import asyncio +import codecs +import re +import time +from urllib.parse import urlencode, urlparse + +import httpx +from scrapy import Selector + +from assets import res +from utils.website.core import Previewer, Req, Utils +from utils.website.info import Dm5BookInfo, Episode + + +class _Dm5Contract: + name = "dm5" + proxy_policy = "direct" + domain = "www.dm5.com" + index = f"https://{domain}/" + search_path = "/search" + search_url_head = f"{index.rstrip('/')}{search_path}?" + update_page = f"https://{domain}/manhua-new/" + update_api = f"https://{domain}/manhua-new/dm5.ashx?action=getupdatecomics" + mappings = { + res.SPIDER.Completer.update: {"kind": "update", "day": 0}, + } + ua = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5", + "Connection": "keep-alive", + "Pragma": "no-cache", + "Cache-Control": "no-cache", + } + headers = ua + book_hea = ua + image_ua = { + "User-Agent": ua["User-Agent"], + "Accept": "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5", + "Accept-Language": ua["Accept-Language"], + "Connection": "keep-alive", + "Pragma": "no-cache", + "Cache-Control": "no-cache", + } + + +class _Dm5ReaderDecoder: + _packed_re = re.compile( + r"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(?P.*)',(?P\d+),(?P\d+),'(?P.*)'\.split\('\|'\),0,\{\}\)\)", + re.S, + ) + _cid_re = re.compile(r"\bvar\s+cid\s*=\s*(?P\d+)\s*;", re.S) + _pix_re = re.compile(r"\bvar\s+pix\s*=\s*(?P\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')\s*;", re.S) + _pvalue_re = re.compile(r"\bvar\s+pvalue\s*=\s*\[(?P.*?)\]\s*;", re.S) + _suffix_re = re.compile( + r"pvalue\s*\[\s*i\s*\]\s*=\s*pix\s*\+\s*pvalue\s*\[\s*i\s*\]\s*\+\s*(?P\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')\s*;", + re.S, + ) + _string_literal_re = re.compile(r"\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*'", re.S) + _page_no_re = re.compile(r"/(?P\d+)_") + + @staticmethod + def _decode_js_string_literal(raw: str) -> str: + normalized = str(raw or "").strip() + if len(normalized) < 2 or normalized[0] not in {'"', "'"} or normalized[-1] != normalized[0]: + raise ValueError(f"dm5 reader invalid js string literal: {raw!r}") + return codecs.decode(normalized[1:-1], "unicode_escape") + + @classmethod + def _encode_unpack_token(cls, index: int, radix: int) -> str: + if index == 0: + return "0" + chars = "0123456789abcdefghijklmnopqrstuvwxyz" + token = "" + while index: + index, remainder = divmod(index, radix) + token = (chr(remainder + 29) if remainder > 35 else chars[remainder]) + token + return token + + @classmethod + def _unpack_script(cls, script_text: str, *, request_url: str) -> str: + matched = cls._packed_re.search(script_text) + if not matched: + if "dm5imagefun" in script_text and "pvalue" in script_text: + return script_text + raise ValueError(f"dm5 chapterfun response missing packed eval payload: url={request_url}") + source = codecs.decode(matched.group("source"), "unicode_escape") + radix = int(matched.group("radix")) + count = int(matched.group("count")) + dictionary = codecs.decode(matched.group("dictionary"), "unicode_escape").split("|") + for index in range(count - 1, -1, -1): + if index >= len(dictionary) or not dictionary[index]: + continue + token = cls._encode_unpack_token(index, radix) + source = re.sub(r"\b" + re.escape(token) + r"\b", dictionary[index], source) + return source + + @classmethod + def decode_page_urls( + cls, + script_text: str, + *, + request_url: str, + expected_cid: str | None = None, + ) -> list[tuple[int, str]]: + unpacked = cls._unpack_script(script_text, request_url=request_url) + cid_match = cls._cid_re.search(unpacked) + if not cid_match: + raise ValueError(f"dm5 decoded reader payload missing cid: url={request_url}") + cid = cid_match.group("value") + if expected_cid is not None and str(expected_cid) != cid: + raise ValueError(f"dm5 decoded reader cid mismatch: expected={expected_cid} actual={cid} url={request_url}") + + pix_match = cls._pix_re.search(unpacked) + if not pix_match: + raise ValueError(f"dm5 decoded reader payload missing pix: url={request_url}") + pix = cls._decode_js_string_literal(pix_match.group("value")) + + pvalue_match = cls._pvalue_re.search(unpacked) + if not pvalue_match: + raise ValueError(f"dm5 decoded reader payload missing pvalue array: url={request_url}") + paths = [ + cls._decode_js_string_literal(matched.group(0)) + for matched in cls._string_literal_re.finditer(pvalue_match.group("body")) + ] + if not paths: + raise ValueError(f"dm5 decoded reader payload returned empty pvalue array: url={request_url}") + + suffix_match = cls._suffix_re.search(unpacked) + if not suffix_match: + raise ValueError(f"dm5 decoded reader payload missing image query suffix: url={request_url}") + suffix = cls._decode_js_string_literal(suffix_match.group("value")) + + page_urls = [] + for path in paths: + page_match = cls._page_no_re.search(path) + if not page_match: + raise ValueError(f"dm5 decoded reader path missing page index: path={path!r} url={request_url}") + page_urls.append((int(page_match.group("page")), f"{pix}{path}{suffix}")) + return page_urls + + +class Dm5Parser(_Dm5Contract, Previewer): + _reader_decoder = _Dm5ReaderDecoder() + _style_url_re = re.compile(r"url\((['\"]?)(?P.+?)\1\)", re.I) + _book_path_re = re.compile(r"/manhua-(?P[^/?#]+)/?", re.I) + _chapter_id_re = re.compile(r"/m(?P\d+)(?:-p\d+)?/?", re.I) + _page_count_re = re.compile(r"(\d+)\s*P", re.I) + _onclick_mid_re = re.compile(r"(?:GetFirstChapterUrl|SetBookmarker)\([^\d]*(?P\d+)") + + @staticmethod + def _normalize_text(value: str | None) -> str: + return " ".join((value or "").split()) + + @classmethod + def _extract_style_url(cls, value: str | None) -> str | None: + matched = cls._style_url_re.search(str(value or "")) + if matched: + return cls._normalize_text(matched.group("url")) + return None + + @classmethod + def _new_book(cls, *, idx: int, url: str) -> Dm5BookInfo: + return Dm5BookInfo(idx=idx, render_keys=["name", "latest_sec"], url=url, preview_url=url) + + @classmethod + def _book_url(cls, slug_or_url: str, *, domain: str) -> str: + raw_value = cls._normalize_text(slug_or_url) + if not raw_value: + raise ValueError("dm5 book slug/url is required") + if raw_value.startswith("http") or raw_value.startswith("/"): + normalized = cls.normalize_preview_resource(raw_value, domain=domain) + if not normalized: + raise ValueError(f"dm5 book url normalization failed: {slug_or_url!r}") + return normalized + return f"https://{domain}/manhua-{raw_value.strip('/')}/" + + @classmethod + def _extract_book_mid(cls, node) -> str | None: + for value in node.xpath(".//@onclick").getall(): + if matched := cls._onclick_mid_re.search(str(value or "")): + return matched.group("mid") + return None + + @classmethod + def _clean_latest_chapter(cls, value: str | None, *, title: str | None = None) -> str: + cleaned = cls._normalize_text(value) + normalized_title = cls._normalize_text(title) + if normalized_title and cleaned.startswith(normalized_title): + cleaned = cls._normalize_text(cleaned[len(normalized_title):].lstrip(" :-")) + if cleaned in {"开始阅读", "阅读"}: + return "" + return cleaned + + @classmethod + def _apply_latest(cls, book: Dm5BookInfo, value: str | None) -> str: + latest = cls._clean_latest_chapter(value, title=book.name) + if latest: + book.latest_sec = latest + book.last_chapter_name = latest + return latest + + @classmethod + def _apply_artists(cls, book: Dm5BookInfo, values: list[str]) -> None: + artists = [cls._normalize_text(value) for value in values] + artists = [value for value in artists if value] + if artists: + book.artist = " ".join(artists) + + @classmethod + def _parse_html_card(cls, node, *, idx: int, domain: str) -> Dm5BookInfo | None: + href = cls._normalize_text( + node.xpath(".//h2[contains(@class,'title')]//a[1]/@href").get() + or node.xpath(".//p[contains(@class,'title')]//a[1]/@href").get() + or node.xpath(".//a[contains(@href,'/manhua-')][1]/@href").get() + ) + if not href: + return None + title = cls._normalize_text( + node.xpath(".//h2[contains(@class,'title')]//a[1]/@title").get() + or node.xpath(".//h2[contains(@class,'title')]//a[1]/text()").get() + or node.xpath(".//p[contains(@class,'title')]//a[1]/@title").get() + or node.xpath(".//p[contains(@class,'title')]//a[1]/text()").get() + ) + if not title: + return None + book_url = cls._book_url(href, domain=domain) + book = cls._new_book(idx=idx, url=book_url) + book.name = title + book.id = cls._extract_book_mid(node) or "" + latest = cls._normalize_text( + node.xpath(".//p[contains(@class,'chapter')]//a[1]/@title").get() + or node.xpath(".//p[contains(@class,'chapter')]//a[1]/text()").get() + ) + cls._apply_latest(book, latest) + cls._apply_artists( + book, + node.xpath(".//p[contains(@class,'author')]//a/text()").getall() + or node.xpath(".//p[contains(@class,'subtitle')]//a/text()").getall() + or node.xpath(".//p[contains(@class,'zl')][span[contains(.,'作者')]]//a/text()").getall(), + ) + cover = cls._extract_style_url(node.xpath(".//p[contains(@class,'mh-cover')][1]/@style").get()) + if cover: + book.img_preview = cls.normalize_preview_resource(cover, domain=domain) + return book + + @classmethod + def _parse_featured_search_card(cls, node, *, idx: int, domain: str) -> Dm5BookInfo | None: + href = cls._normalize_text(node.xpath(".//p[contains(@class,'title')]//a[1]/@href").get()) + title = cls._normalize_text( + node.xpath(".//p[contains(@class,'title')]//a[1]/@title").get() + or node.xpath(".//p[contains(@class,'title')]//a[1]/text()").get() + ) + if not href or not title: + return None + book = cls._new_book(idx=idx, url=cls._book_url(href, domain=domain)) + book.name = title + book.id = cls._extract_book_mid(node) or "" + cls._apply_artists(book, node.xpath(".//p[contains(@class,'subtitle')]//a/text()").getall()) + latest = cls._normalize_text(node.xpath(".//a[contains(@class,'btn-2')][1]/@title").get()) + cls._apply_latest(book, latest) + cover = cls._normalize_text(node.xpath(".//div[contains(@class,'cover')]//img[1]/@src").get()) + if cover: + book.img_preview = cls.normalize_preview_resource(cover, domain=domain) + return book + + @classmethod + def parse_html_books(cls, html_text: str, *, domain: str) -> list[Dm5BookInfo]: + sel = Selector(text=html_text) + books: list[Dm5BookInfo] = [] + seen_urls = set() + + def _append(book: Dm5BookInfo | None): + if book is None: + return + if book.url in seen_urls: + return + seen_urls.add(book.url) + book.idx = len(books) + 1 + books.append(book) + + for node in sel.css("div.banner_detail_form"): + _append(cls._parse_featured_search_card(node, idx=len(books) + 1, domain=domain)) + for node in sel.css("div.mh-item"): + _append(cls._parse_html_card(node, idx=len(books) + 1, domain=domain)) + return books + + @classmethod + def parse_search_document(cls, html_text: str, *, domain: str) -> list[Dm5BookInfo]: + return cls.parse_html_books(html_text, domain=domain) + + @classmethod + def parse_update_payload(cls, payload, *, domain: str) -> list[Dm5BookInfo]: + if not isinstance(payload, dict): + raise TypeError(f"dm5 update payload must be object, got {type(payload).__name__}") + items = payload.get("UpdateComicItems") + if not isinstance(items, list): + raise ValueError("dm5 update payload missing UpdateComicItems list") + books = [] + for idx, item in enumerate(items, start=1): + if not isinstance(item, dict): + raise TypeError(f"dm5 update item must be object, got {type(item).__name__}") + slug = cls._normalize_text(item.get("UrlKey")) + title = cls._normalize_text(item.get("Title")) + if not slug or not title: + continue + book = cls._new_book(idx=idx, url=cls._book_url(slug, domain=domain)) + book.id = cls._normalize_text(str(item.get("ID") or "")) + book.name = title + cls._apply_latest(book, item.get("ShowLastPartName")) + authors = item.get("Author") if isinstance(item.get("Author"), list) else [] + cls._apply_artists(book, [str(value) for value in authors]) + book.public_date = cls._normalize_text(item.get("LastUpdateTime")) or None + cover = cls._normalize_text(item.get("ShowPicUrlB") or item.get("ShowConver") or item.get("Logo")) + if cover: + book.img_preview = cls.normalize_preview_resource(cover, domain=domain) + books.append(book) + return books + + @classmethod + def _extract_js_var(cls, text: str, name: str): + matched = re.search(rf"\b{name}\s*=\s*(?P\"[^\"]*\"|'[^']*'|\d+)\s*;", text) + if not matched: + raise ValueError(f"dm5 html missing js variable: {name}") + value = matched.group("value").strip() + if value[:1] in {'"', "'"}: + return value[1:-1] + return value + + @classmethod + def parse_book_details(cls, html_text: str, *, request_url: str) -> dict: + sel = Selector(text=html_text) + title = cls._normalize_text( + "".join(sel.xpath("//div[contains(@class,'banner_detail_form')]//p[contains(@class,'title')][1]/text()[normalize-space()]").getall()) + ) + if not title: + raise ValueError(f"dm5 book page missing title: url={request_url}") + cover = cls._normalize_text( + sel.xpath("//div[contains(@class,'banner_detail_form')]//div[contains(@class,'cover')]//img[1]/@src").get() + ) + artists = [cls._normalize_text(text) for text in sel.xpath("//p[contains(@class,'subtitle')]//a/text()").getall()] + tags = [cls._normalize_text(text) for text in sel.xpath("//p[contains(@class,'tip')]//a//span/text()").getall()] + latest = cls._normalize_text(sel.xpath("//div[contains(@class,'detail-list-title')]//span[contains(@class,'s')]//a[1]/text()").get()) + return { + "id": cls._extract_js_var(html_text, "DM5_COMIC_MID"), + "title": title, + "cover": cover or None, + "artist": " ".join(filter(None, artists)) or None, + "tags": [tag for tag in tags if tag], + "latest_sec": latest or None, + } + + @classmethod + def apply_book_details(cls, book: Dm5BookInfo, details: dict, *, domain: str) -> None: + if details.get("id"): + book.id = str(details["id"]) + if details.get("title"): + book.name = cls._normalize_text(details["title"]) + cover = cls._normalize_text(details.get("cover")) + if cover: + book.img_preview = cls.normalize_preview_resource(cover, domain=domain) + artist = cls._normalize_text(details.get("artist")) + if artist: + book.artist = artist + tags = list(details.get("tags") or []) + if tags: + book.tags = tags + cls._apply_latest(book, details.get("latest_sec") or book.latest_sec) + + @classmethod + def parse_chapter_id(cls, url: str) -> str: + matched = cls._chapter_id_re.search(str(url or "")) + if not matched: + raise ValueError(f"dm5 chapter url missing /m/ shape: {url!r}") + return matched.group("cid") + + @classmethod + def parse_episodes(cls, html_text: str, book: Dm5BookInfo, *, domain: str) -> list[Episode]: + sel = Selector(text=html_text) + rows = list(sel.css("div#chapterlistload ul.view-win-list a[href*='/m']")) + if not rows: + raise ValueError(f"dm5 book page returned no chapter rows: book={book.url}") + episodes = [] + seen_urls = set() + for row in reversed(rows): + href = cls._normalize_text(row.xpath("./@href").get()) + if not href: + continue + chapter_url = cls.normalize_preview_resource(href, domain=domain) + if chapter_url in seen_urls: + continue + seen_urls.add(chapter_url) + name = cls._normalize_text("".join(row.xpath("./text()").getall())) + if not name: + raise ValueError(f"dm5 chapter row missing title: href={href}") + episode = Episode( + from_book=book, + id=cls.parse_chapter_id(chapter_url), + idx=len(episodes) + 1, + url=chapter_url, + name=name, + ) + page_text = cls._normalize_text("".join(row.xpath("./span/text()").getall())) + if matched := cls._page_count_re.search(page_text): + episode.pages = int(matched.group(1)) + episode.chapter_referer = chapter_url + episodes.append(episode) + return episodes + + @classmethod + def parse_reader_context(cls, html_text: str, *, chapter_url: str) -> dict: + image_count = int(cls._extract_js_var(html_text, "DM5_IMAGE_COUNT")) + if image_count < 1: + raise ValueError(f"dm5 chapter page returned invalid DM5_IMAGE_COUNT: url={chapter_url}") + request_domain = urlparse(chapter_url).netloc or cls.domain + dm5_curl = str(cls._extract_js_var(html_text, "DM5_CURL")) + canonical_chapter_url = cls.normalize_preview_resource(dm5_curl, domain=request_domain) or chapter_url + chapterfun_url = f"{canonical_chapter_url.rstrip('/')}/chapterfun.ashx" + sel = Selector(text=html_text) + dm5_key = cls._normalize_text(sel.xpath("//input[@id='dm5_key']/@value").get()) + return { + "DM5_CURL": dm5_curl, + "DM5_MID": str(cls._extract_js_var(html_text, "DM5_MID")), + "DM5_CID": str(cls._extract_js_var(html_text, "DM5_CID")), + "DM5_IMAGE_COUNT": image_count, + "DM5_PAGEINDEX": int(cls._extract_js_var(html_text, "DM5_PAGEINDEX")), + "DM5_VIEWSIGN": str(cls._extract_js_var(html_text, "DM5_VIEWSIGN")), + "DM5_VIEWSIGN_DT": str(cls._extract_js_var(html_text, "DM5_VIEWSIGN_DT")), + "DM5_KEY": dm5_key, + "DM5_LANGUAGE": "1", + "DM5_GTK": "6", + "DM5_CHAPTERFUN_URL": chapterfun_url, + "DM5_CHAPTER_URL": canonical_chapter_url, + } + + @classmethod + def build_chapterfun_request( + cls, + reader_context: dict, + *, + page: int, + ) -> tuple[str, dict[str, str], dict[str, str]]: + chapter_url = str(reader_context["DM5_CHAPTER_URL"]) + return ( + str(reader_context["DM5_CHAPTERFUN_URL"]), + { + **cls.ua, + "Accept": "*/*", + "X-Requested-With": "XMLHttpRequest", + "Referer": chapter_url, + }, + { + "cid": str(reader_context["DM5_CID"]), + "page": str(max(1, int(page))), + "key": str(reader_context.get("DM5_KEY", "")), + "language": str(reader_context.get("DM5_LANGUAGE", "1")), + "gtk": str(reader_context.get("DM5_GTK", "6")), + "_cid": str(reader_context["DM5_CID"]), + "_mid": str(reader_context["DM5_MID"]), + "_dt": str(reader_context["DM5_VIEWSIGN_DT"]), + "_sign": str(reader_context["DM5_VIEWSIGN"]), + }, + ) + + @classmethod + def decode_chapterfun_page_urls( + cls, + script_text: str, + *, + request_url: str, + expected_cid: str | None = None, + ) -> list[tuple[int, str]]: + return cls._reader_decoder.decode_page_urls(script_text, request_url=request_url, expected_cid=expected_cid) + + @classmethod + def build_image_headers(cls, *, referer_url: str | None = None) -> dict[str, str]: + headers = dict(cls.image_ua) + if referer_url: + headers["Referer"] = referer_url + return headers + + @classmethod + def build_page_urls(cls, chunks: list[list[tuple[int, str]]], *, total: int, request_url: str) -> list[str]: + page_map: dict[int, str] = {} + for chunk in chunks: + for page_no, url in chunk: + if not 1 <= int(page_no) <= int(total): + raise ValueError(f"dm5 decoded page index out of range: page={page_no} total={total} url={request_url}") + previous = page_map.get(page_no) + if previous is not None and previous != url: + raise ValueError(f"dm5 decoded page url conflict: page={page_no} url={request_url}") + page_map[page_no] = url + missing = [page for page in range(1, int(total) + 1) if page not in page_map] + if missing: + preview = ",".join(str(page) for page in missing[:10]) + suffix = "..." if len(missing) > 10 else "" + raise ValueError(f"dm5 chapterfun payload incomplete: missing={preview}{suffix} total={total} url={request_url}") + return [page_map[page] for page in range(1, int(total) + 1)] + + +class Dm5Reqer(_Dm5Contract, Req): + update_page_size = 140 + + def __init__(self, _conf): + self.cli = self.get_cli(_conf) + + @classmethod + def build_search_url(cls, keyword: str, *, domain: str, page: int = 1) -> str: + query = {"title": keyword.strip(), "language": "1"} + if page > 1: + query["page"] = str(page) + return f"https://{domain}{cls.search_path}?{urlencode(query)}" + + @classmethod + def build_mapping_url(cls, mapping_value, *, domain: str) -> str: + if isinstance(mapping_value, dict): + raw_value = mapping_value.get("url") or mapping_value.get("value") or "" + else: + raw_value = mapping_value + url = cls.normalize_preview_resource(str(raw_value or ""), domain=domain) + if not url: + raise ValueError("dm5 mapping URL is required") + return url + + @classmethod + def is_update_mapping(cls, mapping_value) -> bool: + return isinstance(mapping_value, dict) and mapping_value.get("kind") == "update" + + @classmethod + def mapping_update_day(cls, mapping_value) -> int: + if not isinstance(mapping_value, dict): + return 0 + return max(0, int(mapping_value.get("day", 0) or 0)) + + @classmethod + def build_update_request(cls, *, domain: str, page: int, day: int = 0) -> tuple[str, dict[str, str], dict[str, str]]: + stamp = int(time.time() * 1000) + url = f"https://{domain}/manhua-new/dm5.ashx?action=getupdatecomics&d={stamp}" + headers = { + **cls.ua, + "Accept": "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Origin": cls.preview_origin(domain), + "Referer": f"https://{domain}/manhua-new/", + } + data = {"page": str(max(1, int(page or 1))), "pagesize": str(cls.update_page_size), "DK": str(day)} + return url, headers, data + + def test_index(self): + try: + resp = self.cli.get(self.index, headers=self.ua, follow_redirects=True, timeout=6) + resp.raise_for_status() + except httpx.HTTPError: + return False + return bool(resp.text) + + async def preview_search(self, keyword: str, *, page: int = 1): + owner = self._require_preview_owner() + owner_type = type(owner) + site_kw = self.preview_site_kwargs() + domain = site_kw.get("domain") or getattr(self, "domain", None) or owner_type.domain + mappings = owner_type.merge_search_mappings(self.mappings, site_kw.get("custom_map")) + page = max(1, int(page or 1)) + keyword = keyword.strip() + + if keyword in mappings: + mapping_value = mappings[keyword] + if self.is_update_mapping(mapping_value): + url, headers, data = self.build_update_request(domain=domain, page=page, day=self.mapping_update_day(mapping_value)) + resp = await self.ensure_preview_client().post(url, data=data, headers=headers, follow_redirects=True, timeout=12) + resp.raise_for_status() + return await asyncio.to_thread(owner.parser.parse_update_payload, resp.json(), domain=domain) + url = self.build_mapping_url(mapping_value, domain=domain) + resp = await self.ensure_preview_client().get(url, headers=self.ua, follow_redirects=True, timeout=12) + resp.raise_for_status() + html_domain = urlparse(str(resp.url)).netloc or domain + return await asyncio.to_thread(owner.parser.parse_html_books, resp.text, domain=html_domain) + + resp = await self.ensure_preview_client().get( + self.build_search_url(keyword, domain=domain, page=page), + headers=self.ua, + follow_redirects=True, + timeout=12, + ) + resp.raise_for_status() + html_domain = urlparse(str(resp.url)).netloc or domain + return await asyncio.to_thread(owner.parser.parse_search_document, resp.text, domain=html_domain) + + async def preview_fetch_episodes(self, book): + owner = self._require_preview_owner() + site_kw = self.preview_site_kwargs() + domain = site_kw.get("domain") or getattr(self, "domain", None) or type(owner).domain + resp = await self.ensure_preview_client().get(book.url, headers=self.ua, follow_redirects=True, timeout=12) + resp.raise_for_status() + book.url = str(resp.url) + actual_domain = urlparse(book.url).netloc or domain + details = await asyncio.to_thread(owner.parser.parse_book_details, resp.text, request_url=book.url) + owner.parser.apply_book_details(book, details, domain=actual_domain) + return await asyncio.to_thread(owner.parser.parse_episodes, resp.text, book, domain=actual_domain) + + async def _fetch_reader_page_urls(self, reader_context: dict) -> list[str]: + owner = self._require_preview_owner() + total = int(reader_context["DM5_IMAGE_COUNT"]) + request_url = str(reader_context["DM5_CHAPTERFUN_URL"]) + page_chunks: list[list[tuple[int, str]]] = [] + next_page = 1 + + while next_page <= total: + url, headers, params = owner.parser.build_chapterfun_request(reader_context, page=next_page) + resp = await self.ensure_preview_client().get( + url, + params=params, + headers=headers, + follow_redirects=True, + timeout=12, + ) + resp.raise_for_status() + chunk = await asyncio.to_thread( + owner.parser.decode_chapterfun_page_urls, + resp.text, + request_url=str(resp.url), + expected_cid=str(reader_context["DM5_CID"]), + ) + if not chunk: + raise ValueError(f"dm5 chapterfun returned empty image chunk: url={resp.url}") + page_chunks.append(chunk) + chunk_last_page = max(page_no for page_no, _url in chunk) + if chunk_last_page < next_page: + raise ValueError(f"dm5 chapterfun pagination made no progress: page={next_page} url={resp.url}") + if chunk_last_page >= total: + break + next_page = chunk_last_page + 1 + + return owner.parser.build_page_urls(page_chunks, total=total, request_url=request_url) + + async def preview_fetch_pages(self, episode) -> list[str]: + owner = self._require_preview_owner() + site_kw = self.preview_site_kwargs() + domain = site_kw.get("domain") or getattr(self, "domain", None) or type(owner).domain + resp = await self.ensure_preview_client().get(episode.url, headers=self.ua, follow_redirects=True, timeout=12) + resp.raise_for_status() + episode.url = str(resp.url) + reader_context = await asyncio.to_thread(owner.parser.parse_reader_context, resp.text, chapter_url=episode.url) + page_urls = await self._fetch_reader_page_urls(reader_context) + chapter_referer = str(reader_context["DM5_CHAPTER_URL"]) + for key, value in reader_context.items(): + setattr(episode, key, value) + episode.dm5_reader_context = dict(reader_context) + episode.dm5_image_headers = owner.parser.build_image_headers(referer_url=chapter_referer) + episode.chapter_referer = chapter_referer + episode.pages = len(page_urls) + episode.page_urls = list(page_urls) + return list(episode.page_urls) + + +class Dm5Utils(_Dm5Contract, Utils, Previewer): + parser = Dm5Parser + reqer_cls = Dm5Reqer + + def __init__(self, _conf): + self.reqer = self.reqer_cls(_conf) + self.parser = self.__class__.parser + + @classmethod + def preview_client_config(cls, **context): + return {"headers": cls.ua} diff --git a/variables/__init__.py b/variables/__init__.py index e9dee43b..590dee0f 100644 --- a/variables/__init__.py +++ b/variables/__init__.py @@ -21,6 +21,7 @@ class Spider(IntEnum): NHENTAI = 9 # 🌎 🔞 JESTFUL = 10 # 🌎 MANHUAGUI = 11 # 🇨🇳 + DM5 = 12 # 🇨🇳 @property def spider_name(self): return self.name.lower() @@ -28,7 +29,7 @@ def spider_name(self): return self.name.lower() @classmethod def specials(cls): return frozenset({cls.JM, cls.WNACG, cls.EHENTAI, cls.HITOMI, cls.H_COMIC, cls.NHENTAI}) @classmethod - def mangas(cls): return frozenset({cls.MANGA_COPY, cls.MANGABZ, cls.JESTFUL, cls.MANHUAGUI}) + def mangas(cls): return frozenset({cls.MANGA_COPY, cls.MANGABZ, cls.JESTFUL, cls.MANHUAGUI, cls.DM5}) @classmethod def cn_proxy(cls): return frozenset({cls.WNACG, cls.EHENTAI, cls.HITOMI, cls.H_COMIC, cls.NHENTAI, cls.MANHUAGUI}) @classmethod @@ -49,6 +50,8 @@ def _label(s: Spider) -> str: match s.spider_name: case "manga_copy": return f"{s.value}、拷贝漫画" + case "dm5": + return f"{s.value}、动漫屋" case _: return f"{s.value}、{s.spider_name}" @@ -78,6 +81,7 @@ def _label(s: Spider) -> str: Spider.NHENTAI: [res.SPIDER.Completer.update], Spider.JESTFUL: [res.SPIDER.Completer.index,res.SPIDER.Completer.update], Spider.MANHUAGUI: [res.SPIDER.Completer.index,res.SPIDER.Completer.update], + Spider.DM5: [res.SPIDER.Completer.update], }), SCRIPT_SITE_INDEX: [], }.items())) @@ -94,6 +98,7 @@ def _label(s: Spider) -> str: Spider.NHENTAI: f"nhentai: {res.GUI.SearchInputStatusTip.common}", Spider.JESTFUL: f"jestful: {res.GUI.SearchInputStatusTip.common}", Spider.MANHUAGUI: f"manhuagui: {res.GUI.SearchInputStatusTip.common}", + Spider.DM5: f"dm5: {res.GUI.SearchInputStatusTip.common}", }), }.items())) From 00f2f4747d6dec416e54606c1080d3ab5c714a92 Mon Sep 17 00:00:00 2001 From: json Date: Mon, 18 May 2026 02:16:17 +0800 Subject: [PATCH 06/11] feat: add dm5 --- assets/res/locale/en_US.yml | 1 + assets/res/locale/zh_CN.yml | 1 + .../{providers/dm5.py => dm5/__init__.py} | 674 +++++++++++------- utils/website/dm5/reader_decoder.py | 264 +++++++ utils/website/providers/__init__.py | 2 +- variables/__init__.py | 2 +- 6 files changed, 685 insertions(+), 259 deletions(-) rename utils/website/{providers/dm5.py => dm5/__init__.py} (50%) create mode 100644 utils/website/dm5/reader_decoder.py diff --git a/assets/res/locale/en_US.yml b/assets/res/locale/en_US.yml index 94150c60..63995d7c 100644 --- a/assets/res/locale/en_US.yml +++ b/assets/res/locale/en_US.yml @@ -61,6 +61,7 @@ GUI: SearchInputStatusTip: common: (1)Enter `Keyword` show results. (2)Enter Space show Preset-Completer manga_copy: (1)Enter `Keyword` show results. (2)Enter Space show Preset-Completer + dm5: Rank types can be exposed by `conf.custom_map` URLs with `t=1~13`; builtin period suffixes support `week/month/total`, and omitted suffix defaults to week jm: (1)Enter `Keyword` show results. (2)Enter Space show Preset-Completer wnacg: (1)Enter `Keyword` show results. (2)Enter Space show Preset-Completer hitomi: Temp only supports using presets or `hitomi-tools` generated words diff --git a/assets/res/locale/zh_CN.yml b/assets/res/locale/zh_CN.yml index 1a6c3486..7c0977a1 100644 --- a/assets/res/locale/zh_CN.yml +++ b/assets/res/locale/zh_CN.yml @@ -61,6 +61,7 @@ GUI: SearchInputStatusTip: common: '直接输入关键词搜索 或 预设' manga_copy: '搜索规则补充:排名+日/周/月/总+轻小说/男/女,例如"排名轻小说月"' + dm5: '搜索规则补充:综合榜+周/月/总,默认"周",可在配置 映射 自填 t=1~13' jm: '搜索规则补充:时间维度可选 日/周/月/总,例如"收藏总" Ⅱ. 翻页规则:"&page=\d+"' wnacg: '翻页规则:搜索是"&p=\d+" 导航页是"-page-\d+" 如不满足请联系开发者' hitomi: '暂时仅支持使用预设 或 `hitomi-tools`生成的词' diff --git a/utils/website/providers/dm5.py b/utils/website/dm5/__init__.py similarity index 50% rename from utils/website/providers/dm5.py rename to utils/website/dm5/__init__.py index 822803f8..e1f30630 100644 --- a/utils/website/providers/dm5.py +++ b/utils/website/dm5/__init__.py @@ -1,9 +1,9 @@ from __future__ import annotations import asyncio -import codecs import re import time +from dataclasses import dataclass, field from urllib.parse import urlencode, urlparse import httpx @@ -13,6 +13,7 @@ from utils.website.core import Previewer, Req, Utils from utils.website.info import Dm5BookInfo, Episode +from .reader_decoder import Dm5ReaderDecoder class _Dm5Contract: name = "dm5" @@ -23,9 +24,15 @@ class _Dm5Contract: search_url_head = f"{index.rstrip('/')}{search_path}?" update_page = f"https://{domain}/manhua-new/" update_api = f"https://{domain}/manhua-new/dm5.ashx?action=getupdatecomics" - mappings = { - res.SPIDER.Completer.update: {"kind": "update", "day": 0}, + rank_path = "/manhua-rank/" + rank_url_head = f"https://{domain}{rank_path}" + rank_types = { + 1: "国漫榜",2: "日漫榜",3: "综合榜",4: "人气榜",5: "收藏榜",6: "评论榜",7: "上升榜",8: "完结榜", + 9: "少年漫画榜",10: "少女漫画榜",11: "热血冒险榜",12: "幻想脑洞榜",13: "恋爱后宫榜", } + rank_period_labels = ("周", "月", "总") + rank_default_period = rank_period_labels[0] + mappings = {res.SPIDER.Completer.update: {"kind": "update", "day": 0}} ua = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -46,114 +53,67 @@ class _Dm5Contract: } -class _Dm5ReaderDecoder: - _packed_re = re.compile( - r"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(?P.*)',(?P\d+),(?P\d+),'(?P.*)'\.split\('\|'\),0,\{\}\)\)", - re.S, - ) - _cid_re = re.compile(r"\bvar\s+cid\s*=\s*(?P\d+)\s*;", re.S) - _pix_re = re.compile(r"\bvar\s+pix\s*=\s*(?P\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')\s*;", re.S) - _pvalue_re = re.compile(r"\bvar\s+pvalue\s*=\s*\[(?P.*?)\]\s*;", re.S) - _suffix_re = re.compile( - r"pvalue\s*\[\s*i\s*\]\s*=\s*pix\s*\+\s*pvalue\s*\[\s*i\s*\]\s*\+\s*(?P\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')\s*;", - re.S, - ) - _string_literal_re = re.compile(r"\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*'", re.S) - _page_no_re = re.compile(r"/(?P\d+)_") - - @staticmethod - def _decode_js_string_literal(raw: str) -> str: - normalized = str(raw or "").strip() - if len(normalized) < 2 or normalized[0] not in {'"', "'"} or normalized[-1] != normalized[0]: - raise ValueError(f"dm5 reader invalid js string literal: {raw!r}") - return codecs.decode(normalized[1:-1], "unicode_escape") - - @classmethod - def _encode_unpack_token(cls, index: int, radix: int) -> str: - if index == 0: - return "0" - chars = "0123456789abcdefghijklmnopqrstuvwxyz" - token = "" - while index: - index, remainder = divmod(index, radix) - token = (chr(remainder + 29) if remainder > 35 else chars[remainder]) + token - return token - - @classmethod - def _unpack_script(cls, script_text: str, *, request_url: str) -> str: - matched = cls._packed_re.search(script_text) - if not matched: - if "dm5imagefun" in script_text and "pvalue" in script_text: - return script_text - raise ValueError(f"dm5 chapterfun response missing packed eval payload: url={request_url}") - source = codecs.decode(matched.group("source"), "unicode_escape") - radix = int(matched.group("radix")) - count = int(matched.group("count")) - dictionary = codecs.decode(matched.group("dictionary"), "unicode_escape").split("|") - for index in range(count - 1, -1, -1): - if index >= len(dictionary) or not dictionary[index]: - continue - token = cls._encode_unpack_token(index, radix) - source = re.sub(r"\b" + re.escape(token) + r"\b", dictionary[index], source) - return source - - @classmethod - def decode_page_urls( - cls, - script_text: str, - *, - request_url: str, - expected_cid: str | None = None, - ) -> list[tuple[int, str]]: - unpacked = cls._unpack_script(script_text, request_url=request_url) - cid_match = cls._cid_re.search(unpacked) - if not cid_match: - raise ValueError(f"dm5 decoded reader payload missing cid: url={request_url}") - cid = cid_match.group("value") - if expected_cid is not None and str(expected_cid) != cid: - raise ValueError(f"dm5 decoded reader cid mismatch: expected={expected_cid} actual={cid} url={request_url}") - - pix_match = cls._pix_re.search(unpacked) - if not pix_match: - raise ValueError(f"dm5 decoded reader payload missing pix: url={request_url}") - pix = cls._decode_js_string_literal(pix_match.group("value")) - - pvalue_match = cls._pvalue_re.search(unpacked) - if not pvalue_match: - raise ValueError(f"dm5 decoded reader payload missing pvalue array: url={request_url}") - paths = [ - cls._decode_js_string_literal(matched.group(0)) - for matched in cls._string_literal_re.finditer(pvalue_match.group("body")) - ] - if not paths: - raise ValueError(f"dm5 decoded reader payload returned empty pvalue array: url={request_url}") - - suffix_match = cls._suffix_re.search(unpacked) - if not suffix_match: - raise ValueError(f"dm5 decoded reader payload missing image query suffix: url={request_url}") - suffix = cls._decode_js_string_literal(suffix_match.group("value")) - - page_urls = [] - for path in paths: - page_match = cls._page_no_re.search(path) - if not page_match: - raise ValueError(f"dm5 decoded reader path missing page index: path={path!r} url={request_url}") - page_urls.append((int(page_match.group("page")), f"{pix}{path}{suffix}")) - return page_urls +@dataclass(frozen=True, slots=True) +class _Dm5PreviewRoute: + kind: str + method: str + url: str + headers: dict[str, str] + parser_name: str + response_format: str = "html" + data: dict[str, str] | None = None + parser_kwargs: dict[str, object] = field(default_factory=dict) class Dm5Parser(_Dm5Contract, Previewer): - _reader_decoder = _Dm5ReaderDecoder() + _reader_decoder = Dm5ReaderDecoder() _style_url_re = re.compile(r"url\((['\"]?)(?P.+?)\1\)", re.I) _book_path_re = re.compile(r"/manhua-(?P[^/?#]+)/?", re.I) _chapter_id_re = re.compile(r"/m(?P\d+)(?:-p\d+)?/?", re.I) _page_count_re = re.compile(r"(\d+)\s*P", re.I) _onclick_mid_re = re.compile(r"(?:GetFirstChapterUrl|SetBookmarker)\([^\d]*(?P\d+)") + _rank_type_re = re.compile(r"[?&]t=(?P\d+)") + _title_link_xpath = "(.//h2[contains(@class,'title')]//a | .//p[contains(@class,'title')]//a)[1]" + _chapter_title_xpath = "(.//p[contains(@class,'chapter')]//a/@title | .//p[contains(@class,'chapter')]//a/text())[1]" @staticmethod def _normalize_text(value: str | None) -> str: return " ".join((value or "").split()) + @classmethod + def parse_rank_periods(cls, html_text: str) -> list[str]: + sel = Selector(text=html_text) + periods = [cls._normalize_text(value) for value in sel.css("p.top-type a::text").getall()] + return [value for value in periods if value] + + @classmethod + def parse_active_rank_period(cls, html_text: str) -> str | None: + sel = Selector(text=html_text) + return cls._normalize_text(sel.css("p.top-type a.active::text").get()) + + @classmethod + def parse_rank_menu(cls, html_text: str) -> dict[int, str]: + sel = Selector(text=html_text) + rank_menu = {} + for node in sel.css("figure.top-menu a[href*='/manhua-rank/?t=']"): + href = cls._normalize_text(node.xpath("./@href").get()) + label = cls._normalize_text("".join(node.xpath("./text()").getall())) + if not href or not label: + continue + if matched := cls._rank_type_re.search(href): + rank_menu[int(matched.group("rank_type"))] = label + return rank_menu + + @classmethod + def parse_active_rank_type(cls, html_text: str) -> int | None: + sel = Selector(text=html_text) + href = cls._normalize_text(sel.css("figure.top-menu a.active::attr(href)").get()) + if not href: + return None + if matched := cls._rank_type_re.search(href): + return int(matched.group("rank_type")) + return None + @classmethod def _extract_style_url(cls, value: str | None) -> str | None: matched = cls._style_url_re.search(str(value or "")) @@ -175,7 +135,10 @@ def _book_url(cls, slug_or_url: str, *, domain: str) -> str: if not normalized: raise ValueError(f"dm5 book url normalization failed: {slug_or_url!r}") return normalized - return f"https://{domain}/manhua-{raw_value.strip('/')}/" + slug = raw_value.strip("/") + if slug.startswith("manhua-"): + return f"https://{domain}/{slug}/" + return f"https://{domain}/manhua-{slug}/" @classmethod def _extract_book_mid(cls, node) -> str | None: @@ -209,37 +172,36 @@ def _apply_artists(cls, book: Dm5BookInfo, values: list[str]) -> None: if artists: book.artist = " ".join(artists) + @classmethod + def _append_unique_book(cls, books: list[Dm5BookInfo], seen_urls: set[str], book: Dm5BookInfo | None) -> None: + if book is None: + return + if book.url in seen_urls: + return + seen_urls.add(book.url) + book.idx = len(books) + 1 + books.append(book) + @classmethod def _parse_html_card(cls, node, *, idx: int, domain: str) -> Dm5BookInfo | None: + title_link = node.xpath(cls._title_link_xpath) href = cls._normalize_text( - node.xpath(".//h2[contains(@class,'title')]//a[1]/@href").get() - or node.xpath(".//p[contains(@class,'title')]//a[1]/@href").get() + title_link.xpath("./@href").get() or node.xpath(".//a[contains(@href,'/manhua-')][1]/@href").get() ) - if not href: - return None - title = cls._normalize_text( - node.xpath(".//h2[contains(@class,'title')]//a[1]/@title").get() - or node.xpath(".//h2[contains(@class,'title')]//a[1]/text()").get() - or node.xpath(".//p[contains(@class,'title')]//a[1]/@title").get() - or node.xpath(".//p[contains(@class,'title')]//a[1]/text()").get() - ) - if not title: + title = cls._normalize_text(title_link.xpath("./@title").get() or title_link.xpath("normalize-space(string())").get()) + if not href or not title: return None book_url = cls._book_url(href, domain=domain) book = cls._new_book(idx=idx, url=book_url) book.name = title book.id = cls._extract_book_mid(node) or "" - latest = cls._normalize_text( - node.xpath(".//p[contains(@class,'chapter')]//a[1]/@title").get() - or node.xpath(".//p[contains(@class,'chapter')]//a[1]/text()").get() - ) - cls._apply_latest(book, latest) + cls._apply_latest(book, node.xpath(cls._chapter_title_xpath).get()) cls._apply_artists( book, - node.xpath(".//p[contains(@class,'author')]//a/text()").getall() - or node.xpath(".//p[contains(@class,'subtitle')]//a/text()").getall() - or node.xpath(".//p[contains(@class,'zl')][span[contains(.,'作者')]]//a/text()").getall(), + node.xpath("./div[contains(@class,'mh-item-detali')]//p[contains(@class,'subtitle')]//a/text()").getall() + or node.xpath("./div[contains(@class,'mh-item-detali')]//p[contains(@class,'zl')][span[contains(.,'作者')]]//a/text()").getall() + or node.xpath(".//div[contains(@class,'mh-item-tip-detali')]//p[contains(@class,'author')]//a/text()").getall(), ) cover = cls._extract_style_url(node.xpath(".//p[contains(@class,'mh-cover')][1]/@style").get()) if cover: @@ -248,19 +210,16 @@ def _parse_html_card(cls, node, *, idx: int, domain: str) -> Dm5BookInfo | None: @classmethod def _parse_featured_search_card(cls, node, *, idx: int, domain: str) -> Dm5BookInfo | None: - href = cls._normalize_text(node.xpath(".//p[contains(@class,'title')]//a[1]/@href").get()) - title = cls._normalize_text( - node.xpath(".//p[contains(@class,'title')]//a[1]/@title").get() - or node.xpath(".//p[contains(@class,'title')]//a[1]/text()").get() - ) + title_link = node.xpath(cls._title_link_xpath) + href = cls._normalize_text(title_link.xpath("./@href").get()) + title = cls._normalize_text(title_link.xpath("./@title").get() or title_link.xpath("normalize-space(string())").get()) if not href or not title: return None book = cls._new_book(idx=idx, url=cls._book_url(href, domain=domain)) book.name = title book.id = cls._extract_book_mid(node) or "" cls._apply_artists(book, node.xpath(".//p[contains(@class,'subtitle')]//a/text()").getall()) - latest = cls._normalize_text(node.xpath(".//a[contains(@class,'btn-2')][1]/@title").get()) - cls._apply_latest(book, latest) + cls._apply_latest(book, node.xpath("(.//a[contains(@class,'btn-2')][1]/@title | .//a[contains(@class,'btn-2')][1]/text())[1]").get()) cover = cls._normalize_text(node.xpath(".//div[contains(@class,'cover')]//img[1]/@src").get()) if cover: book.img_preview = cls.normalize_preview_resource(cover, domain=domain) @@ -270,21 +229,36 @@ def _parse_featured_search_card(cls, node, *, idx: int, domain: str) -> Dm5BookI def parse_html_books(cls, html_text: str, *, domain: str) -> list[Dm5BookInfo]: sel = Selector(text=html_text) books: list[Dm5BookInfo] = [] - seen_urls = set() - - def _append(book: Dm5BookInfo | None): - if book is None: - return - if book.url in seen_urls: - return - seen_urls.add(book.url) - book.idx = len(books) + 1 - books.append(book) + seen_urls: set[str] = set() for node in sel.css("div.banner_detail_form"): - _append(cls._parse_featured_search_card(node, idx=len(books) + 1, domain=domain)) + cls._append_unique_book(books, seen_urls, cls._parse_featured_search_card(node, idx=len(books) + 1, domain=domain)) for node in sel.css("div.mh-item"): - _append(cls._parse_html_card(node, idx=len(books) + 1, domain=domain)) + cls._append_unique_book(books, seen_urls, cls._parse_html_card(node, idx=len(books) + 1, domain=domain)) + return books + + @classmethod + def parse_rank_books(cls, html_text: str, *, domain: str, period: str | None = None) -> list[Dm5BookInfo]: + sel = Selector(text=html_text) + panels = list(sel.css("ul.mh-list.top-cat")) + if not panels: + return cls.parse_html_books(html_text, domain=domain) + + labels = cls.parse_rank_periods(html_text) + requested_period = cls._normalize_text(period) or cls.parse_active_rank_period(html_text) or cls.rank_default_period + if requested_period not in cls.rank_period_labels: + raise ValueError(f"dm5 rank period must be one of {','.join(cls.rank_period_labels)}, got {requested_period!r}") + + period_index = labels.index(requested_period) if requested_period in labels else cls.rank_period_labels.index(requested_period) + if period_index >= len(panels): + raise ValueError( + f"dm5 rank period panel missing: period={requested_period} panels={len(panels)} labels={labels or list(cls.rank_period_labels)}" + ) + + books: list[Dm5BookInfo] = [] + seen_urls: set[str] = set() + for node in panels[period_index].css("div.mh-item"): + cls._append_unique_book(books, seen_urls, cls._parse_html_card(node, idx=len(books) + 1, domain=domain)) return books @classmethod @@ -379,7 +353,7 @@ def parse_chapter_id(cls, url: str) -> str: @classmethod def parse_episodes(cls, html_text: str, book: Dm5BookInfo, *, domain: str) -> list[Episode]: sel = Selector(text=html_text) - rows = list(sel.css("div#chapterlistload ul.view-win-list a[href*='/m']")) + rows = list(sel.css("div#chapterlistload a[href*='/m']")) if not rows: raise ValueError(f"dm5 book page returned no chapter rows: book={book.url}") episodes = [] @@ -411,29 +385,7 @@ def parse_episodes(cls, html_text: str, book: Dm5BookInfo, *, domain: str) -> li @classmethod def parse_reader_context(cls, html_text: str, *, chapter_url: str) -> dict: - image_count = int(cls._extract_js_var(html_text, "DM5_IMAGE_COUNT")) - if image_count < 1: - raise ValueError(f"dm5 chapter page returned invalid DM5_IMAGE_COUNT: url={chapter_url}") - request_domain = urlparse(chapter_url).netloc or cls.domain - dm5_curl = str(cls._extract_js_var(html_text, "DM5_CURL")) - canonical_chapter_url = cls.normalize_preview_resource(dm5_curl, domain=request_domain) or chapter_url - chapterfun_url = f"{canonical_chapter_url.rstrip('/')}/chapterfun.ashx" - sel = Selector(text=html_text) - dm5_key = cls._normalize_text(sel.xpath("//input[@id='dm5_key']/@value").get()) - return { - "DM5_CURL": dm5_curl, - "DM5_MID": str(cls._extract_js_var(html_text, "DM5_MID")), - "DM5_CID": str(cls._extract_js_var(html_text, "DM5_CID")), - "DM5_IMAGE_COUNT": image_count, - "DM5_PAGEINDEX": int(cls._extract_js_var(html_text, "DM5_PAGEINDEX")), - "DM5_VIEWSIGN": str(cls._extract_js_var(html_text, "DM5_VIEWSIGN")), - "DM5_VIEWSIGN_DT": str(cls._extract_js_var(html_text, "DM5_VIEWSIGN_DT")), - "DM5_KEY": dm5_key, - "DM5_LANGUAGE": "1", - "DM5_GTK": "6", - "DM5_CHAPTERFUN_URL": chapterfun_url, - "DM5_CHAPTER_URL": canonical_chapter_url, - } + return _ChapterfunSession.from_reader_html(cls, html_text, chapter_url=chapter_url).to_reader_context() @classmethod def build_chapterfun_request( @@ -442,27 +394,7 @@ def build_chapterfun_request( *, page: int, ) -> tuple[str, dict[str, str], dict[str, str]]: - chapter_url = str(reader_context["DM5_CHAPTER_URL"]) - return ( - str(reader_context["DM5_CHAPTERFUN_URL"]), - { - **cls.ua, - "Accept": "*/*", - "X-Requested-With": "XMLHttpRequest", - "Referer": chapter_url, - }, - { - "cid": str(reader_context["DM5_CID"]), - "page": str(max(1, int(page))), - "key": str(reader_context.get("DM5_KEY", "")), - "language": str(reader_context.get("DM5_LANGUAGE", "1")), - "gtk": str(reader_context.get("DM5_GTK", "6")), - "_cid": str(reader_context["DM5_CID"]), - "_mid": str(reader_context["DM5_MID"]), - "_dt": str(reader_context["DM5_VIEWSIGN_DT"]), - "_sign": str(reader_context["DM5_VIEWSIGN"]), - }, - ) + return _ChapterfunSession.from_reader_context(reader_context).build_request(page=page).as_tuple() @classmethod def decode_chapterfun_page_urls( @@ -483,6 +415,134 @@ def build_image_headers(cls, *, referer_url: str | None = None) -> dict[str, str @classmethod def build_page_urls(cls, chunks: list[list[tuple[int, str]]], *, total: int, request_url: str) -> list[str]: + return _ChapterfunSession.aggregate_page_urls(chunks, total=total, request_url=request_url) + + +@dataclass(frozen=True, slots=True) +class _ChapterfunRequest: + page: int + url: str + headers: dict[str, str] + params: dict[str, str] + + def as_tuple(self) -> tuple[str, dict[str, str], dict[str, str]]: + return self.url, self.headers, self.params + + +@dataclass(slots=True) +class _ChapterfunSession: + curl: str + chapter_url: str + chapterfun_url: str + cid: str + mid: str + image_count: int + pageindex: int + viewsign: str + viewsign_dt: str + key: str + language: str = "1" + gtk: str = "6" + next_page: int = 1 + page_chunks: list[list[tuple[int, str]]] = field(default_factory=list) + + @classmethod + def from_reader_html(cls, parser: type[Dm5Parser], html_text: str, *, chapter_url: str) -> _ChapterfunSession: + image_count = int(parser._extract_js_var(html_text, "DM5_IMAGE_COUNT")) + if image_count < 1: + raise ValueError(f"dm5 chapter page returned invalid DM5_IMAGE_COUNT: url={chapter_url}") + request_domain = urlparse(chapter_url).netloc or parser.domain + dm5_curl = str(parser._extract_js_var(html_text, "DM5_CURL")) + canonical_chapter_url = parser.normalize_preview_resource(dm5_curl, domain=request_domain) or chapter_url + sel = Selector(text=html_text) + return cls( + curl=dm5_curl, + chapter_url=canonical_chapter_url, + chapterfun_url=f"{canonical_chapter_url.rstrip('/')}/chapterfun.ashx", + cid=str(parser._extract_js_var(html_text, "DM5_CID")), + mid=str(parser._extract_js_var(html_text, "DM5_MID")), + image_count=image_count, + pageindex=int(parser._extract_js_var(html_text, "DM5_PAGEINDEX")), + viewsign=str(parser._extract_js_var(html_text, "DM5_VIEWSIGN")), + viewsign_dt=str(parser._extract_js_var(html_text, "DM5_VIEWSIGN_DT")), + key=parser._normalize_text(sel.xpath("//input[@id='dm5_key']/@value").get()), + ) + + @classmethod + def from_reader_context(cls, reader_context: dict) -> _ChapterfunSession: + return cls( + curl=str(reader_context["DM5_CURL"]), + chapter_url=str(reader_context["DM5_CHAPTER_URL"]), + chapterfun_url=str(reader_context["DM5_CHAPTERFUN_URL"]), + cid=str(reader_context["DM5_CID"]), + mid=str(reader_context["DM5_MID"]), + image_count=int(reader_context["DM5_IMAGE_COUNT"]), + pageindex=int(reader_context["DM5_PAGEINDEX"]), + viewsign=str(reader_context["DM5_VIEWSIGN"]), + viewsign_dt=str(reader_context["DM5_VIEWSIGN_DT"]), + key=str(reader_context.get("DM5_KEY", "")), + language=str(reader_context.get("DM5_LANGUAGE", "1")), + gtk=str(reader_context.get("DM5_GTK", "6")), + ) + + def to_reader_context(self) -> dict[str, str | int]: + return { + "DM5_CURL": self.curl, + "DM5_MID": self.mid, + "DM5_CID": self.cid, + "DM5_IMAGE_COUNT": self.image_count, + "DM5_PAGEINDEX": self.pageindex, + "DM5_VIEWSIGN": self.viewsign, + "DM5_VIEWSIGN_DT": self.viewsign_dt, + "DM5_KEY": self.key, + "DM5_LANGUAGE": self.language, + "DM5_GTK": self.gtk, + "DM5_CHAPTERFUN_URL": self.chapterfun_url, + "DM5_CHAPTER_URL": self.chapter_url, + } + + def has_pending_request(self) -> bool: + return self.next_page <= self.image_count + + def build_request(self, *, page: int | None = None) -> _ChapterfunRequest: + request_page = self.next_page if page is None else max(1, int(page)) + return _ChapterfunRequest( + page=request_page, + url=self.chapterfun_url, + headers={ + **_Dm5Contract.ua, + "Accept": "*/*", + "X-Requested-With": "XMLHttpRequest", + "Referer": self.chapter_url, + }, + params={ + "cid": self.cid, + "page": str(request_page), + "key": self.key, + "language": self.language, + "gtk": self.gtk, + "_cid": self.cid, + "_mid": self.mid, + "_dt": self.viewsign_dt, + "_sign": self.viewsign, + }, + ) + + def record_chunk(self, chunk: list[tuple[int, str]], *, request_url: str) -> None: + if not chunk: + raise ValueError(f"dm5 chapterfun returned empty image chunk: url={request_url}") + self.page_chunks.append(chunk) + chunk_last_page = max(page_no for page_no, _url in chunk) + if chunk_last_page < self.next_page: + raise ValueError(f"dm5 chapterfun pagination made no progress: page={self.next_page} url={request_url}") + self.next_page = self.image_count + 1 if chunk_last_page >= self.image_count else chunk_last_page + + def accept_response(self, parser: type[Dm5Parser], script_text: str, *, request_url: str) -> None: + chunk = parser.decode_chapterfun_page_urls(script_text, request_url=request_url, expected_cid=self.cid) + self.record_chunk(chunk, request_url=request_url) + + @staticmethod + def aggregate_page_urls(chunks: list[list[tuple[int, str]]], *, total: int, request_url: str) -> list[str]: page_map: dict[int, str] = {} for chunk in chunks: for page_no, url in chunk: @@ -499,6 +559,24 @@ def build_page_urls(cls, chunks: list[list[tuple[int, str]]], *, total: int, req raise ValueError(f"dm5 chapterfun payload incomplete: missing={preview}{suffix} total={total} url={request_url}") return [page_map[page] for page in range(1, int(total) + 1)] + def build_page_urls(self) -> list[str]: + return self.aggregate_page_urls(self.page_chunks, total=self.image_count, request_url=self.chapterfun_url) + + def build_image_headers(self) -> dict[str, str]: + headers = dict(_Dm5Contract.image_ua) + headers["Referer"] = self.chapter_url + return headers + + def apply_to_episode(self, episode, *, page_urls: list[str]) -> None: + reader_context = self.to_reader_context() + for key, value in reader_context.items(): + setattr(episode, key, value) + episode.dm5_reader_context = dict(reader_context) + episode.dm5_image_headers = self.build_image_headers() + episode.chapter_referer = self.chapter_url + episode.pages = len(page_urls) + episode.page_urls = list(page_urls) + class Dm5Reqer(_Dm5Contract, Req): update_page_size = 140 @@ -514,12 +592,39 @@ def build_search_url(cls, keyword: str, *, domain: str, page: int = 1) -> str: return f"https://{domain}{cls.search_path}?{urlencode(query)}" @classmethod - def build_mapping_url(cls, mapping_value, *, domain: str) -> str: + def build_rank_url(cls, rank_type: int, *, domain: str) -> str: + rank_type = int(rank_type) + if rank_type not in cls.rank_types: + raise ValueError(f"dm5 rank type must be in 1..13, got {rank_type}") + return f"https://{domain}{cls.rank_path}?t={rank_type}" + + @staticmethod + def _normalize_keyword(keyword: str | None) -> str: + return "".join(str(keyword or "").split()) + + @classmethod + def _mapping_raw_value(cls, mapping_value) -> str: if isinstance(mapping_value, dict): - raw_value = mapping_value.get("url") or mapping_value.get("value") or "" - else: - raw_value = mapping_value - url = cls.normalize_preview_resource(str(raw_value or ""), domain=domain) + mapping_value = mapping_value.get("url") or mapping_value.get("value") or "" + return str(mapping_value or "").strip() + + @classmethod + def rank_custom_map_examples(cls, *, domain: str) -> dict[str, str]: + return { + label: cls.build_rank_url(rank_type, domain=domain) + for rank_type, label in cls.rank_types.items() + } + + @classmethod + def build_mapping_url(cls, mapping_value, *, domain: str) -> str: + raw_value = cls._mapping_raw_value(mapping_value) + parsed = urlparse(raw_value) + normalized_path = Previewer.normalize_preview_resource(parsed.path or raw_value, domain=domain) + if not normalized_path: + raise ValueError("dm5 mapping URL is required") + url = normalized_path + if parsed.query: + url = f"{url}?{parsed.query}" if not url: raise ValueError("dm5 mapping URL is required") return url @@ -534,6 +639,55 @@ def mapping_update_day(cls, mapping_value) -> int: return 0 return max(0, int(mapping_value.get("day", 0) or 0)) + @classmethod + def parse_rank_type_from_mapping(cls, mapping_value, *, domain: str) -> int | None: + try: + url = cls.build_mapping_url(mapping_value, domain=domain) + except ValueError: + return None + parsed = urlparse(url) + if parsed.path.rstrip("/") != cls.rank_path.rstrip("/"): + return None + matched = re.search(r"(?:^|[?&])t=(?P\d+)", parsed.query) + if not matched: + return None + rank_type = int(matched.group("rank_type")) + return rank_type if rank_type in cls.rank_types else None + + @classmethod + def resolve_rank_search_spec(cls, keyword: str, *, mappings: dict | None, domain: str) -> dict | None: + normalized_keyword = cls._normalize_keyword(keyword) + if not normalized_keyword: + return None + + period = cls.rank_default_period + rank_keyword = normalized_keyword + if normalized_keyword[-1:] in cls.rank_period_labels: + period = normalized_keyword[-1] + rank_keyword = normalized_keyword[:-1] + if not rank_keyword: + return None + + rank_labels = { + cls._normalize_keyword(label): rank_type + for rank_type, label in cls.rank_types.items() + } + for mapping_key, mapping_value in (mappings or {}).items(): + rank_type = cls.parse_rank_type_from_mapping(mapping_value, domain=domain) + if rank_type is None: + continue + normalized_key = cls._normalize_keyword(mapping_key) + if normalized_key: + rank_labels[normalized_key] = rank_type + + rank_type = rank_labels.get(rank_keyword) + if rank_type is None: + return None + return { + "keyword": rank_keyword,"period": period,"rank_type": rank_type,"rank_label": cls.rank_types[rank_type], + "url": cls.build_rank_url(rank_type, domain=domain), + } + @classmethod def build_update_request(cls, *, domain: str, page: int, day: int = 0) -> tuple[str, dict[str, str], dict[str, str]]: stamp = int(time.time() * 1000) @@ -543,12 +697,59 @@ def build_update_request(cls, *, domain: str, page: int, day: int = 0) -> tuple[ "Accept": "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", - "Origin": cls.preview_origin(domain), + "Origin": f"https://{domain}", "Referer": f"https://{domain}/manhua-new/", } - data = {"page": str(max(1, int(page or 1))), "pagesize": str(cls.update_page_size), "DK": str(day)} + preview_page = max(1, int(page or 1)) + request_day = max(0, int(day or 0)) + (preview_page - 1) + data = {"page": "1", "pagesize": str(cls.update_page_size), "DK": str(request_day)} return url, headers, data + @classmethod + def resolve_preview_route(cls, keyword: str, *, page: int, domain: str, mappings: dict | None) -> _Dm5PreviewRoute: + normalized_keyword = keyword.strip() + if normalized_keyword in (mappings or {}): + mapping_value = mappings[normalized_keyword] + if cls.is_update_mapping(mapping_value): + url, headers, data = cls.build_update_request(domain=domain, page=page, day=cls.mapping_update_day(mapping_value)) + return _Dm5PreviewRoute( + kind="update", + method="POST", + url=url, + headers=headers, + data=data, + parser_name="parse_update_payload", + response_format="json", + ) + + rank_spec = cls.resolve_rank_search_spec(normalized_keyword, mappings=mappings, domain=domain) + if rank_spec is not None: + return _Dm5PreviewRoute( + kind="rank", + method="GET", + url=rank_spec["url"], + headers=dict(cls.ua), + parser_name="parse_rank_books", + parser_kwargs={"period": rank_spec["period"]}, + ) + + if normalized_keyword in (mappings or {}): + return _Dm5PreviewRoute( + kind="mapping", + method="GET", + url=cls.build_mapping_url(mappings[normalized_keyword], domain=domain), + headers=dict(cls.ua), + parser_name="parse_html_books", + ) + + return _Dm5PreviewRoute( + kind="search", + method="GET", + url=cls.build_search_url(normalized_keyword, domain=domain, page=page), + headers=dict(cls.ua), + parser_name="parse_search_document", + ) + def test_index(self): try: resp = self.cli.get(self.index, headers=self.ua, follow_redirects=True, timeout=6) @@ -564,30 +765,20 @@ async def preview_search(self, keyword: str, *, page: int = 1): domain = site_kw.get("domain") or getattr(self, "domain", None) or owner_type.domain mappings = owner_type.merge_search_mappings(self.mappings, site_kw.get("custom_map")) page = max(1, int(page or 1)) - keyword = keyword.strip() - - if keyword in mappings: - mapping_value = mappings[keyword] - if self.is_update_mapping(mapping_value): - url, headers, data = self.build_update_request(domain=domain, page=page, day=self.mapping_update_day(mapping_value)) - resp = await self.ensure_preview_client().post(url, data=data, headers=headers, follow_redirects=True, timeout=12) - resp.raise_for_status() - return await asyncio.to_thread(owner.parser.parse_update_payload, resp.json(), domain=domain) - url = self.build_mapping_url(mapping_value, domain=domain) - resp = await self.ensure_preview_client().get(url, headers=self.ua, follow_redirects=True, timeout=12) - resp.raise_for_status() - html_domain = urlparse(str(resp.url)).netloc or domain - return await asyncio.to_thread(owner.parser.parse_html_books, resp.text, domain=html_domain) - - resp = await self.ensure_preview_client().get( - self.build_search_url(keyword, domain=domain, page=page), - headers=self.ua, - follow_redirects=True, - timeout=12, - ) + route = self.resolve_preview_route(keyword, page=page, domain=domain, mappings=mappings) + request_kw = {"headers": route.headers, "follow_redirects": True, "timeout": 12} + if route.data is not None: + request_kw["data"] = route.data + resp = await self.ensure_preview_client().request(route.method, route.url, **request_kw) resp.raise_for_status() - html_domain = urlparse(str(resp.url)).netloc or domain - return await asyncio.to_thread(owner.parser.parse_search_document, resp.text, domain=html_domain) + parser = getattr(owner.parser, route.parser_name) + if route.response_format == "json": + payload = resp.json() + parse_domain = domain + else: + payload = resp.text + parse_domain = urlparse(str(resp.url)).netloc or domain + return await asyncio.to_thread(parser, payload, domain=parse_domain, **route.parser_kwargs) async def preview_fetch_episodes(self, book): owner = self._require_preview_owner() @@ -601,41 +792,6 @@ async def preview_fetch_episodes(self, book): owner.parser.apply_book_details(book, details, domain=actual_domain) return await asyncio.to_thread(owner.parser.parse_episodes, resp.text, book, domain=actual_domain) - async def _fetch_reader_page_urls(self, reader_context: dict) -> list[str]: - owner = self._require_preview_owner() - total = int(reader_context["DM5_IMAGE_COUNT"]) - request_url = str(reader_context["DM5_CHAPTERFUN_URL"]) - page_chunks: list[list[tuple[int, str]]] = [] - next_page = 1 - - while next_page <= total: - url, headers, params = owner.parser.build_chapterfun_request(reader_context, page=next_page) - resp = await self.ensure_preview_client().get( - url, - params=params, - headers=headers, - follow_redirects=True, - timeout=12, - ) - resp.raise_for_status() - chunk = await asyncio.to_thread( - owner.parser.decode_chapterfun_page_urls, - resp.text, - request_url=str(resp.url), - expected_cid=str(reader_context["DM5_CID"]), - ) - if not chunk: - raise ValueError(f"dm5 chapterfun returned empty image chunk: url={resp.url}") - page_chunks.append(chunk) - chunk_last_page = max(page_no for page_no, _url in chunk) - if chunk_last_page < next_page: - raise ValueError(f"dm5 chapterfun pagination made no progress: page={next_page} url={resp.url}") - if chunk_last_page >= total: - break - next_page = chunk_last_page + 1 - - return owner.parser.build_page_urls(page_chunks, total=total, request_url=request_url) - async def preview_fetch_pages(self, episode) -> list[str]: owner = self._require_preview_owner() site_kw = self.preview_site_kwargs() @@ -643,16 +799,20 @@ async def preview_fetch_pages(self, episode) -> list[str]: resp = await self.ensure_preview_client().get(episode.url, headers=self.ua, follow_redirects=True, timeout=12) resp.raise_for_status() episode.url = str(resp.url) - reader_context = await asyncio.to_thread(owner.parser.parse_reader_context, resp.text, chapter_url=episode.url) - page_urls = await self._fetch_reader_page_urls(reader_context) - chapter_referer = str(reader_context["DM5_CHAPTER_URL"]) - for key, value in reader_context.items(): - setattr(episode, key, value) - episode.dm5_reader_context = dict(reader_context) - episode.dm5_image_headers = owner.parser.build_image_headers(referer_url=chapter_referer) - episode.chapter_referer = chapter_referer - episode.pages = len(page_urls) - episode.page_urls = list(page_urls) + chapterfun = await asyncio.to_thread(_ChapterfunSession.from_reader_html, owner.parser, resp.text, chapter_url=episode.url) + while chapterfun.has_pending_request(): + request = chapterfun.build_request() + chunk_resp = await self.ensure_preview_client().get( + request.url, + params=request.params, + headers=request.headers, + follow_redirects=True, + timeout=12, + ) + chunk_resp.raise_for_status() + await asyncio.to_thread(chapterfun.accept_response, owner.parser, chunk_resp.text, request_url=str(chunk_resp.url)) + page_urls = chapterfun.build_page_urls() + chapterfun.apply_to_episode(episode, page_urls=page_urls) return list(episode.page_urls) diff --git a/utils/website/dm5/reader_decoder.py b/utils/website/dm5/reader_decoder.py new file mode 100644 index 00000000..2652ac66 --- /dev/null +++ b/utils/website/dm5/reader_decoder.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import ast +import re + + +class Dm5ReaderDecoder: + _packer_juicers = ( + r"}\s*\(\s*'(.*?)'\s*,\s*(\d+|\[\])\s*,\s*(\d+)\s*,\s*'(.*?)'\.split\('\|'\)(?:\s*,\s*(\d+)\s*,\s*(.*?))?\s*\)\s*\)", + r'}\s*\(\s*"(.*?)"\s*,\s*(\d+|\[\])\s*,\s*(\d+)\s*,\s*"(.*?)"\.split\("\|"\)(?:\s*,\s*(\d+)\s*,\s*(.*?))?\s*\)\s*\)', + ) + + @staticmethod + def _decode_js_string_literal(raw: str) -> str: + normalized = str(raw or "").strip() + if len(normalized) < 2 or normalized[0] not in {'"', "'"} or normalized[-1] != normalized[0]: + raise ValueError(f"dm5 reader invalid js string literal: {raw!r}") + body = normalized[1:-1] + cooked: list[str] = [] + index = 0 + while index < len(body): + if body[index] != "\\" or index + 1 >= len(body): + cooked.append(body[index]) + index += 1 + continue + marker = body[index + 1] + if marker == "/": + cooked.append("/") + index += 2 + continue + if marker == "\n": + index += 2 + continue + if marker == "\r": + index += 2 + if index < len(body) and body[index] == "\n": + index += 1 + continue + cooked.append(body[index]) + cooked.append(marker) + index += 2 + try: + value = ast.literal_eval(f"{normalized[0]}{''.join(cooked)}{normalized[0]}") + except (SyntaxError, ValueError) as exc: + raise ValueError(f"dm5 reader invalid js string literal: {raw!r}") from exc + if not isinstance(value, str): + raise ValueError(f"dm5 reader invalid js string literal: {raw!r}") + return value + + @staticmethod + def _consume_ws(script_text: str, index: int) -> int: + while index < len(script_text) and script_text[index].isspace(): + index += 1 + return index + + @classmethod + def _parse_js_string_at(cls, script_text: str, index: int) -> tuple[str, int]: + index = cls._consume_ws(script_text, index) + if index >= len(script_text) or script_text[index] not in {'"', "'"}: + raise ValueError(f"dm5 reader invalid js string start: index={index}") + quote = script_text[index] + end = index + 1 + while end < len(script_text): + char = script_text[end] + if char == "\\": + end += 2 + continue + if char == quote: + return script_text[index : end + 1], end + 1 + end += 1 + raise ValueError("dm5 reader unterminated js string literal") + + @classmethod + def _iter_js_string_literals(cls, script_text: str): + index = 0 + while index < len(script_text): + if script_text[index] in {'"', "'"}: + raw, next_index = cls._parse_js_string_at(script_text, index) + yield raw + index = next_index + continue + index += 1 + + @classmethod + def _parse_js_int_at(cls, script_text: str, index: int) -> tuple[int, int]: + index = cls._consume_ws(script_text, index) + end = index + while end < len(script_text) and script_text[end].isdigit(): + end += 1 + if end == index: + raise ValueError(f"dm5 reader invalid js int start: index={index}") + return int(script_text[index:end]), end + + @classmethod + def _parse_bracket_block( + cls, + script_text: str, + index: int, + *, + open_char: str = "[", + close_char: str = "]", + ) -> tuple[str, int]: + index = cls._consume_ws(script_text, index) + if index >= len(script_text) or script_text[index] != open_char: + raise ValueError(f"dm5 reader invalid bracket block start: index={index}") + depth = 1 + end = index + 1 + while end < len(script_text): + char = script_text[end] + if char in {'"', "'"}: + _, end = cls._parse_js_string_at(script_text, end) + continue + if char == open_char: + depth += 1 + elif char == close_char: + depth -= 1 + if depth == 0: + return script_text[index + 1 : end], end + 1 + end += 1 + raise ValueError("dm5 reader unterminated bracket block") + + @classmethod + def _find_decl_assignment_index(cls, script_text: str, name: str) -> int: + for prefix in ("var ", "let ", "const "): + marker = f"{prefix}{name}" + start = script_text.find(marker) + if start == -1: + continue + assign = script_text.find("=", start + len(marker)) + if assign == -1: + continue + return assign + 1 + raise ValueError(f"dm5 reader missing {name}") + + @classmethod + def _extract_decl_int(cls, script_text: str, name: str, *, request_url: str) -> int: + try: + value, _ = cls._parse_js_int_at(script_text, cls._find_decl_assignment_index(script_text, name)) + return value + except ValueError as exc: + raise ValueError(f"dm5 reader missing {name}: url={request_url}") from exc + + @classmethod + def _extract_decl_string(cls, script_text: str, name: str, *, request_url: str) -> str: + try: + raw, _ = cls._parse_js_string_at(script_text, cls._find_decl_assignment_index(script_text, name)) + return cls._decode_js_string_literal(raw) + except ValueError as exc: + raise ValueError(f"dm5 reader missing {name}: url={request_url}") from exc + + @classmethod + def _extract_decl_string_list(cls, script_text: str, name: str, *, request_url: str) -> list[str]: + try: + body, _ = cls._parse_bracket_block(script_text, cls._find_decl_assignment_index(script_text, name)) + except ValueError as exc: + raise ValueError(f"dm5 reader missing {name}: url={request_url}") from exc + return [cls._decode_js_string_literal(raw) for raw in cls._iter_js_string_literals(body)] + + @classmethod + def _extract_suffix(cls, script_text: str, *, request_url: str) -> str: + search_from = 0 + while True: + start = script_text.find("pvalue[", search_from) + if start == -1: + break + end = script_text.find(";", start) + if end == -1: + end = len(script_text) + statement = script_text[start:end] + if "=" not in statement or "pix" not in statement or "+" not in statement: + search_from = start + 1 + continue + literals = [cls._decode_js_string_literal(raw) for raw in cls._iter_js_string_literals(statement)] + if literals: + return literals[-1] + search_from = start + 1 + raise ValueError(f"dm5 reader missing suffix: url={request_url}") + + @staticmethod + def _extract_page_number(path: str, *, request_url: str) -> int: + segment = str(path or "").rsplit("/", 1)[-1].split("?", 1)[0].split("#", 1)[0] + if "_" not in segment: + raise ValueError(f"dm5 reader bad page path: {path!r} url={request_url}") + page_text = segment.split("_", 1)[0] + if not page_text.isdigit(): + matched = re.search(r"-(\d+)$", page_text) + if matched is None: + raise ValueError(f"dm5 reader bad page path: {path!r} url={request_url}") + return int(matched.group(1)) + 1 + return int(page_text) + + @classmethod + def _encode_unpack_token(cls, index: int, radix: int) -> str: + if index == 0: + return "0" + chars = "0123456789abcdefghijklmnopqrstuvwxyz" + token = "" + while index: + index, remainder = divmod(index, radix) + token = (chr(remainder + 29) if remainder > 35 else chars[remainder]) + token + return token + + @classmethod + def _parse_packer_args(cls, script_text: str) -> tuple[str, int, int, list[str]] | None: + normalized = str(script_text or "").replace("\\\\'", "\\'") + for pattern in cls._packer_juicers: + matched = re.search(pattern, normalized, re.DOTALL) + if matched is None: + continue + payload, radix, count, dictionary = matched.group(1), matched.group(2), matched.group(3), matched.group(4) + payload = payload.replace("\\\\", "\\").replace("\\'", "'") + radix_value = 62 if radix == "[]" else int(radix) + return payload, radix_value, int(count), dictionary.split("|") + return None + + @classmethod + def _unpack_packer(cls, script_text: str, *, request_url: str) -> str: + parsed = cls._parse_packer_args(script_text) + if parsed is None: + raise ValueError(f"dm5 chapterfun response missing packed eval payload: url={request_url}") + payload, radix, count, dictionary = parsed + if count != len(dictionary): + raise ValueError( + f"dm5 chapterfun packed symtab mismatch: count={count} symtab_len={len(dictionary)} url={request_url}" + ) + replacements = { + cls._encode_unpack_token(index, radix): value for index, value in enumerate(dictionary) if value + } + return re.sub( + r"\b\w+\b", + lambda matched: replacements.get(matched.group(0), matched.group(0)), + payload, + flags=re.ASCII, + ) + + @classmethod + def decode_page_urls( + cls, + script_text: str, + *, + request_url: str, + expected_cid: str | None = None, + ) -> list[tuple[int, str]]: + if cls._parse_packer_args(script_text): + unpacked = cls._unpack_packer(script_text, request_url=request_url) + elif "dm5imagefun" in script_text and "pvalue" in script_text: + unpacked = script_text + else: + raise ValueError(f"dm5 chapterfun response missing packed eval payload: url={request_url}") + + cid = str(cls._extract_decl_int(unpacked, "cid", request_url=request_url)) + if expected_cid is not None and str(expected_cid) != cid: + raise ValueError(f"dm5 reader cid mismatch: expected={expected_cid} actual={cid} url={request_url}") + + pix = cls._extract_decl_string(unpacked, "pix", request_url=request_url) + paths = cls._extract_decl_string_list(unpacked, "pvalue", request_url=request_url) + if not paths: + raise ValueError(f"dm5 reader empty pvalue: url={request_url}") + + suffix = cls._extract_suffix(unpacked, request_url=request_url) + return [ + (cls._extract_page_number(path, request_url=request_url), f"{pix}{path}{suffix}") + for path in paths + ] diff --git a/utils/website/providers/__init__.py b/utils/website/providers/__init__.py index f8af493f..f3e4f823 100644 --- a/utils/website/providers/__init__.py +++ b/utils/website/providers/__init__.py @@ -6,7 +6,7 @@ from .mangabz import * from .jestful import * from .manhuagui import * -from .dm5 import * from ..hitomi import * from .hcomic import * from ..nhentai import * +from ..dm5 import * diff --git a/variables/__init__.py b/variables/__init__.py index 590dee0f..4cbd5457 100644 --- a/variables/__init__.py +++ b/variables/__init__.py @@ -98,7 +98,7 @@ def _label(s: Spider) -> str: Spider.NHENTAI: f"nhentai: {res.GUI.SearchInputStatusTip.common}", Spider.JESTFUL: f"jestful: {res.GUI.SearchInputStatusTip.common}", Spider.MANHUAGUI: f"manhuagui: {res.GUI.SearchInputStatusTip.common}", - Spider.DM5: f"dm5: {res.GUI.SearchInputStatusTip.common}", + Spider.DM5: f"dm5: {res.GUI.SearchInputStatusTip.dm5}", }), }.items())) From 51186a8161367854bebb14f1705fafb800fa61ab Mon Sep 17 00:00:00 2001 From: json Date: Mon, 18 May 2026 17:24:06 +0800 Subject: [PATCH 07/11] upd: dm5 tip --- GUI/manager/preview/__init__.py | 1 + GUI/manager/preview/manga.py | 12 +++++------- GUI/src/preview_format/preview_episode.js | 10 ++++++---- GUI/thread/preview.py | 13 +++++++++++-- assets/res/locale/en_US.yml | 1 + assets/res/locale/zh_CN.yml | 1 + utils/website/core/err.py | 18 +++++++++++++++++- utils/website/dm5/__init__.py | 2 ++ 8 files changed, 44 insertions(+), 14 deletions(-) diff --git a/GUI/manager/preview/__init__.py b/GUI/manager/preview/__init__.py index 82720343..efa9c9fa 100644 --- a/GUI/manager/preview/__init__.py +++ b/GUI/manager/preview/__init__.py @@ -268,6 +268,7 @@ def create_worker(self, gui_site_runtime: GuiSiteRuntime): self._worker.search_done.connect(self._on_search_done) ep_handler = self._fix if self.is_fix else self._manga self._worker.episodes_done.connect(ep_handler.on_episodes_done) + self._worker.episodes_error.connect(ep_handler.on_episodes_error) self._worker.pages_done.connect(ep_handler.on_pages_done) self._worker.cover_done.connect(self.gui.task_mgr.on_cover_preload_success) self._worker.cover_error.connect(self.gui.task_mgr.on_cover_preload_error) diff --git a/GUI/manager/preview/manga.py b/GUI/manager/preview/manga.py index 4fedb892..aacc7882 100644 --- a/GUI/manager/preview/manga.py +++ b/GUI/manager/preview/manga.py @@ -348,12 +348,8 @@ def on_episodes_done(self, generation, session_id, book_key, episodes): for episode in episodes if episode.id_and_md5()[1] in downloaded_md5s } - ep_data = [ - { - "idx": ep.idx, - "name": ep.name, - "downloaded": f"ep{book_key}-{ep.idx}" in downloaded_episode_ids, - } + ep_data = [{"idx": ep.idx, "name": ep.name, + "downloaded": f"ep{book_key}-{ep.idx}" in downloaded_episode_ids} for ep in episodes ] self.mgr.send_command("manga.episodes.loaded", {"bookKey": str(book_key), "episodes": ep_data}, session_id=session_id) @@ -364,7 +360,9 @@ def on_episodes_error(self, generation, session_id, book_key, error): self._inflight_books.discard((session_id, book_key)) if generation != self.mgr._generation or session_id != self.mgr._session_id: return - self.mgr.send_command("manga.episodes.error", {"bookKey": str(book_key), "code": "fetch_failed"}, session_id=session_id) + self.mgr.send_command("manga.episodes.error", {"bookKey": str(book_key), "code": "fetch_failed", "message": str(error or "")}, + session_id=session_id, + ) # ------------------------------------------------------------------ # Pages fetch diff --git a/GUI/src/preview_format/preview_episode.js b/GUI/src/preview_format/preview_episode.js index 810080a8..d45c6a89 100644 --- a/GUI/src/preview_format/preview_episode.js +++ b/GUI/src/preview_format/preview_episode.js @@ -55,8 +55,8 @@ window.previewRuntime.markDownloaded(bookIds, []); } }); - previewCommandBus.register('manga.episodes.error', ({ bookKey, code }) => { - this.showEpisodeFetchError(bookKey, code); + previewCommandBus.register('manga.episodes.error', ({ bookKey, code, message }) => { + this.showEpisodeFetchError(bookKey, code, message); }); previewCommandBus.register('preview.scan.show', ({ message }) => { this.showScanNotification(message); @@ -203,6 +203,7 @@ return; } if (!bridge || typeof bridge.fetchEpisodes !== 'function') { + this.clearPendingTimer(); this.showEpisodeError(this.buildEpisodeErrorMessage('bridge_not_ready')); return; } @@ -445,11 +446,12 @@ this.renderError(message || this.buildEpisodeErrorMessage('fetch_failed')); } - showEpisodeFetchError(bookKey, code = 'fetch_failed') { + showEpisodeFetchError(bookKey, code = 'fetch_failed', message = '') { if (String(bookKey) !== this.activeBookKey) { return; } - this.showEpisodeError(this.buildEpisodeErrorMessage(code)); + this.clearPendingTimer(); + this.showEpisodeError(message || this.buildEpisodeErrorMessage(code)); } applyDlScanResult(badges) { diff --git a/GUI/thread/preview.py b/GUI/thread/preview.py index f270a95e..220c3970 100644 --- a/GUI/thread/preview.py +++ b/GUI/thread/preview.py @@ -6,6 +6,7 @@ from PySide6.QtCore import QThread, Signal +from utils.website.core.err import SiteBusinessError from utils.website.info import Episode from utils.website import ThreadSiteRuntime @@ -46,6 +47,7 @@ class CoverTask: class PreviewWorker(QThread): search_done = Signal(int, str, int, object) episodes_done = Signal(int, int, str, object) + episodes_error = Signal(int, int, str, str) pages_done = Signal(int, str, object) cover_done = Signal(int, str, object) cover_error = Signal(int, str, str) @@ -89,7 +91,10 @@ async def _do_fetch_episodes_batch(self, items): async def _fetch_one(session_id, book_key, book): async with semaphore: - episodes = await self.thread_site_runtime.preview_fetch_episodes(book) + try: + episodes = await self.thread_site_runtime.preview_fetch_episodes(book) + except SiteBusinessError as exc: + return self.episodes_error.emit(self._generation, session_id, book_key, str(exc)) self.episodes_done.emit(self._generation, session_id, book_key, episodes) await asyncio.gather(*[_fetch_one(sid, bk, b) for sid, bk, b in items]) @@ -134,7 +139,11 @@ def run(self): books = self._loop.run_until_complete(self._do_search(kw, self.site_index, page=pg)) self.search_done.emit(self._generation, kw, self.site_index, books) case EpisodesTask(session_id=sid, book_key=bk, book=b): - episodes = self._loop.run_until_complete(self._do_fetch_episodes(b, self.site_index)) + try: + episodes = self._loop.run_until_complete(self._do_fetch_episodes(b, self.site_index)) + except SiteBusinessError as exc: + self.episodes_error.emit(self._generation, sid, bk, str(exc)) + continue self.episodes_done.emit(self._generation, sid, bk, episodes) case EpisodesBatchTask(items=its): self._loop.run_until_complete(self._do_fetch_episodes_batch(its)) diff --git a/assets/res/locale/en_US.yml b/assets/res/locale/en_US.yml index 63995d7c..7181b76c 100644 --- a/assets/res/locale/en_US.yml +++ b/assets/res/locale/en_US.yml @@ -20,6 +20,7 @@ GUI: mangabz_desc: "Māngabz uses iPhone web version. Note: Volumes/chapters both use numeric labels (e.g. Vol.1 and Ch.1 both labeled '1') - identify via adjacent chapters." hitomi_desc: "Except presets, use `hitomi-tools` for input📹References
1. Avoid using search-keyword 2. Hitomi does not support mappings" h_comic_desc: "h-comic is a frontend-rendered site. Prefer short keywords (core title words). If search returns empty, retry with synonyms." + dm5_desc: "dm5 popular comics are likely not available for reading" Script: site_name: "Script" service_check_success: "✅ Service check" diff --git a/assets/res/locale/zh_CN.yml b/assets/res/locale/zh_CN.yml index 7c0977a1..1b1b4b0c 100644 --- a/assets/res/locale/zh_CN.yml +++ b/assets/res/locale/zh_CN.yml @@ -20,6 +20,7 @@ GUI: mangabz_desc: "Māngabz 使用源为iphone网页版,逆天章节只有数字,例如第一卷和第一话都是1,需要根据相邻章节自己鉴别" hitomi_desc: "除预设外,输入请使用 hitomi-tools ,📹参考用法
1.不建议使用搜索,你的关键字极大可能无效
2.hitomi无法使用映射
【国内Tip】不用代理也能跑哦( ̄﹃ ̄)震惊吧,不过预览时访问链接涉及官网域名的就不行了" h_comic_desc: "h-comic 为前端渲染站,建议优先使用简短关键词(如作品名核心词)进行搜索;搜索结果为空时可换同义词重试" + dm5_desc: "dm5 热门版权漫画几率不提供阅读" Script: site_name: "Script" service_check_success: "✅ 后台服务检测" diff --git a/utils/website/core/err.py b/utils/website/core/err.py index 6b5e1e1b..13f74c34 100644 --- a/utils/website/core/err.py +++ b/utils/website/core/err.py @@ -1,3 +1,7 @@ +class SiteBusinessError(ValueError): + """Expected site/business-level exception shown to the operator.""" + + class EhResp: _case_map = { "This IP address has been temporarily banned due to an excessive request rate": @@ -8,4 +12,16 @@ class EhResp: def catch(cls, text): for raw, message in cls._case_map.items(): if raw in text: - raise ValueError(message) + raise SiteBusinessError(message) + + +class Dm5Resp: + _case_map = { + "动漫屋已不再提供": "dm5 不提供此漫画阅读", + } + + @classmethod + def catch(cls, text): + for raw, message in cls._case_map.items(): + if raw in text: + raise SiteBusinessError(message) diff --git a/utils/website/dm5/__init__.py b/utils/website/dm5/__init__.py index e1f30630..1ebcb0e0 100644 --- a/utils/website/dm5/__init__.py +++ b/utils/website/dm5/__init__.py @@ -11,6 +11,7 @@ from assets import res from utils.website.core import Previewer, Req, Utils +from utils.website.core.err import Dm5Resp from utils.website.info import Dm5BookInfo, Episode from .reader_decoder import Dm5ReaderDecoder @@ -353,6 +354,7 @@ def parse_chapter_id(cls, url: str) -> str: @classmethod def parse_episodes(cls, html_text: str, book: Dm5BookInfo, *, domain: str) -> list[Episode]: sel = Selector(text=html_text) + Dm5Resp.catch(html_text) rows = list(sel.css("div#chapterlistload a[href*='/m']")) if not rows: raise ValueError(f"dm5 book page returned no chapter rows: book={book.url}") From e22cc08f1608f68e559715a753f97bd239526964 Mon Sep 17 00:00:00 2001 From: json Date: Mon, 18 May 2026 17:47:26 +0800 Subject: [PATCH 08/11] chore: site add --- README.md | 7 +++-- .../monitor/monitorStatusBoardSource.ts | 28 ++++++++++++++++++ docs/assets/img/icons/website/dm5.png | Bin 0 -> 3449 bytes docs/assets/img/icons/website/jf.svg | 1 + docs/assets/img/icons/website/mhg.png | Bin 0 -> 5953 bytes docs/assets/img/icons/website/nhentai.svg | 6 ++++ docs/feat/index.md | 17 +++++++---- docs/index.md | 14 +++++++++ 8 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 docs/assets/img/icons/website/dm5.png create mode 100644 docs/assets/img/icons/website/jf.svg create mode 100644 docs/assets/img/icons/website/mhg.png create mode 100644 docs/assets/img/icons/website/nhentai.svg diff --git a/README.md b/README.md index 8d093429..0afe0211 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ logo - logo + logo
tag @@ -48,7 +48,10 @@ | [Hitomi](https://hitomi.la/) | 🌏 | | | Script | 🌏 | [Kemono](https://kemono.cr)
[Danbooru](https://danbooru.donmai.us/) | | [HComic](https://h-comic.com/) | 🌏 | 代理 | -| [jestful](https://jestful.net/) | 🌏 | 灰测/表漫/生肉 | +| [Nhentai](https://nhentai.net/) | 🌏 | 代理/ `>=2.10.1-beta` | +| [jestful](https://jestful.net/) | 🌏 | 表漫/生肉 | +| [漫画柜](https://www.manhuagui.com/) | :cn: | 代理/ `>=2.10.1-beta` | +| [dm5(动漫屋)](https://tel.dm5.com/) | :cn: | 代理?/`>=2.10.1-beta.2` | 点进网站前先判断 NSFW , 不了解就勿点 使用请适度,以免加重对方服务器负担,也减少被封ip风险 diff --git a/docs/.vitepress/components/monitor/monitorStatusBoardSource.ts b/docs/.vitepress/components/monitor/monitorStatusBoardSource.ts index 184406d0..4169b9d0 100644 --- a/docs/.vitepress/components/monitor/monitorStatusBoardSource.ts +++ b/docs/.vitepress/components/monitor/monitorStatusBoardSource.ts @@ -6,6 +6,10 @@ const jmSiteAvatarSrc = new URL('../../../assets/img/icons/website/jm.png', impo const mangabzSiteAvatarSrc = new URL('../../../assets/img/icons/website/mangabz.png', import.meta.url).href const wnacgSiteAvatarSrc = new URL('../../../assets/img/icons/website/wnacg.png', import.meta.url).href const danbooruSiteAvatarSrc = new URL('../../../assets/img/icons/website/danbooru.svg', import.meta.url).href +const nhentaiSiteAvatarSrc = new URL('../../../assets/img/icons/website/nhentai.svg', import.meta.url).href +const jestfulSiteAvatarSrc = new URL('../../../assets/img/icons/website/jf.svg', import.meta.url).href +const manhuaguiSiteAvatarSrc = new URL('../../../assets/img/icons/website/mhg.png', import.meta.url).href +const dm5SiteAvatarSrc = new URL('../../../assets/img/icons/website/dm5.png', import.meta.url).href export type MonitorBoardLocale = 'zh' | 'en' @@ -88,6 +92,30 @@ export const monitorBoardSites: MonitorBoardSite[] = [ href: 'https://danbooru.domain.us', avatarSrc: danbooruSiteAvatarSrc, }, + { + id: 'nhentai', + name: 'Nhentai', + href: 'https://nhentai.net/', + avatarSrc: nhentaiSiteAvatarSrc, + }, + { + id: 'jestful', + name: 'jestful', + href: 'https://jestful.net/', + avatarSrc: jestfulSiteAvatarSrc, + }, + { + id: 'manhuagui', + name: '漫画柜', + href: 'https://www.manhuagui.com/', + avatarSrc: manhuaguiSiteAvatarSrc, + }, + { + id: 'dm5', + name: 'dm5', + href: 'https://tel.dm5.com/', + avatarSrc: dm5SiteAvatarSrc, + }, ] export const monitorBoardCopy = { diff --git a/docs/assets/img/icons/website/dm5.png b/docs/assets/img/icons/website/dm5.png new file mode 100644 index 0000000000000000000000000000000000000000..cb169f6ebd3b69a23e39d6c6efa2beab42d7767b GIT binary patch literal 3449 zcmV-<4TkcGP)uc0ujUizj+uf`y9mARg$Qm;6|DW)z!L~d|mgIq_^v&%g1Rd9;s#9gR z=hfD`FV#o9t>@R(?SFOoSPrp zbH(j)Kezzyiv3eR6m|!YeS0xp6kvV`!Z32h{23g;n*_i|e*NhmSu4Y?iwt0&xyG)n zmC+<{X1k-L7&3Suev`Z)GdYB}s!)bozx)ya5rE6lanMM3=T{|=>Hum&8E#Ew0M7!| zeeGKVT0!WO%H^UrDF8f->W2%F3cQxX^RR1DQvt}nIeZ|P)@?wR%FuxzHTj?pASj3D zf!9m1*>CZ!upXX=%*kpdkJa!zT23TP9(><25@Dz8^`&BQ&=j2X+xO-N`>IlW|38E6 zSso94#G>a~nwA^pux3a~VW$LOuD>!!P9N9n^>pl{hfCS!<=~jwg&F~DmvR2Os6!Qz zS^?owm{Wzyi7La?{LaZ|=$Dr2XpdMGp+g?^`{kDY{W8!{32Tndk+ zGpXa|?Kw>wmT9`h=Z*zXoL{F3$njpMjh~Tdt07<2vE+zt|j?50= z;`g((W*Fy7QhCNh1U*^7*#N-$Do3&zR$Zaf(w(47wyr-3!j3utcZV!~aV;3zB00T> zO(L6VLvgxzJ`6kQm$UfwWpkx}=#kL$gVszI`1&XLqi{yBLBR6GU8wkhvJwz9D6N9O z3dhSrqW5m@ihE9{)L;J4Qj72uP9TuaL7p9a#)iHixZQu2!BC>45dxAM-3+DUj+jcI zMl_k!Q;>h+2!sse2$pD%+={0Q;vesARlRhIzC_akhkky}oFsQzSbtN~V7N(eS+A%WZoYKMpw zuR*swG$432NPe(^QLyJVs2&cv<4500?^fp?-2gBU#r2Z)DLt^ZpGN25ce4$c6DtK$ z`65Uh8=eQ;bMMvGVWt98i8moQHvaB&5mM{I*zQ|92Q~EL3408Xp0UM*6BoE5R4WK2!3T_2;ipR491`9OjH)$|XoFsO_*%FVde z`#WSMFQ{mHgN>u*=gJh}xz?$pTH{4GHgs?e0^pin6ejOsTMivIl?nIaSJsklrZyG9 z?3n+WkrWnnb#v84$6N$9x$s5?9{8fj)&ZzSqdSs^eeqs9i~ZLnquzyj1%yo!z#tT) zm>;iCr|s#}V*kgAQy6D&sw`Dq$V6Zh0P?8*Ue<_K z2BBgM0~}0{4=z)f$W_m?2=8;@SbK;(QQYeg*WrExfd=|jsXkD&mgI(B7CznfeQ~W zdsaxq*mO}=?L2s~bHXW4Lg$=g%YB?rV4;KqjH&_v<dSUNI7CkUM6+3(RC(^bbKP zlSVTz25^>Q_zV+ZKzbOpGPZ%eNM30&ai%3XC0a!=Mw?2k6M%_mF{I`jWZecFQvz@F z^ooQss0^qX0`H$Y_0Yi*0BtW2cEIC-o8xTnnHyba34j!lCL-yo5-p8MsX`7z6mt01&A(*u|m;NxplT zVKF$2CS?|K`?+HMH&*2h4uItB>A6riY_ALhNprE~GD<&PuSdH$5ZELnlZF<3^0|s* zB4?wIR--lM0M0p)^Id7@OOqV*jGo~jDxfEy08oj3?=cSuS(TBsqoHR2q!@MF)JY%i zl7y{C^T>=gMcUt-$swhZ`yU^O2(yAWLYo0An+j>2zj!8_fy~sT#mRabu>bfmza7UQX;wH@0tft6+FA8WF$9{ z)XHo4@CkBst( zgqcBB)B=FLL-KK@16b?p5p(K|(~P0Ek4GL|^MQGV?2(KH`3&hR&{ARu@8dHrL*QZ|3!}U^(2l1R z^jDz{%sAMh_sLs`6)8OBw@=!u;6X8ZB@aPCPLgI`A#8z&)4{MbFasc26BZL`LNQph z5DNo{42jZqFOY4aM9LwQ2LMV&81Fqp)}loMP$@A`VrHv3U{K!QaE8Q41=y^NoD&g~ zajWw6cPLEe4a6H3R){5PT6(E)hG=h5pX&f%O%8?n`B&A;OiT4>{cTYkw?#tmYH7D@ zT3GDke3y;cSpZdqR9&LkZ?PKyR8{#{rpFQ7Z!^-X03?U2($P>?%>XL!MtBZ{-U~3T zyRX+}!&)!bbX}$OLtCb3ClAo)gzz5QQy0-%N}M_+rD!*5UK%hn7Jya-2j6*75(&CW zVCr{SPhH$_){(vOEm|q+;Pz(5HqlIpIP{SA~5D0*~3KLGqkHx)eWdfLBT1d1z-`vTBPQw9FO zYd%!Jz;^!VW)f)QDu)L!zqJZlbT9&d#jOE6+*$?X))LU)@-ha1ML+Wc!x+IR0Oq#_ z;NRE_^j0_ufW@r={C8^={C1T9iC1M>B4Qt_^n?CiUaQ#A4Kx7A(=qW(SCVLM6`47y zfY0pOa8KLN;WP*|2{mE^8LA7bcMd6m62PWJ5u4F17}^Jdi!PlmMwU@GC7$!y_+p!z zs~hwi&uf&w00KFxj4@`k?wx&ca8X(yG!TBEzI+B`Yaa?PS_`lwe$lf}&6@#;*n5% zU?exBHW2>51|qNsCS6X0iR)?DS15Em3;0P4J)oQ@0pM`qSAMHW_tOxEiiD`y5qpE+ z?uN*j02s;*TWkOZj{}~;{y1zTG``4Wi({%_N*~j}$|3f_?%X3dq6#K{Z^f}2fY*MD zQ?(7CO*!lS(Eb9yq1OQ3^jz%!?t+V35rQvK%{%hhu;P+c^Aanb;b z#)BWo=vd8+;{&O6B*W^U^&tpo@|^xICfJ%;;;?4;r-cHg9g^3Z3V_no!->G?DG=U( z!N_PF1--#eB$LPHu{PPifdD+2vhV{rf<o(MR7SmVnlutvj^649j*Gm{?oDc b_{{$SX@6p)C{L$N00000NkvXXu0mjfxKU{s literal 0 HcmV?d00001 diff --git a/docs/assets/img/icons/website/jf.svg b/docs/assets/img/icons/website/jf.svg new file mode 100644 index 00000000..8b4c8fd6 --- /dev/null +++ b/docs/assets/img/icons/website/jf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/img/icons/website/mhg.png b/docs/assets/img/icons/website/mhg.png new file mode 100644 index 0000000000000000000000000000000000000000..bfb981bb5a26b033c40ec052ecd7d375395b3b41 GIT binary patch literal 5953 zcmbVQXH*kix26cv6c7;U5J06Tp+{Os0HZYNN;8rG0YYL3gsvb(kR}}|(xrElB1n;5 zl_p(5x=5EU7vJ}Lzk7dt>)y5QtTi*|%z5_n?7h$4vt~_*zOL3KI!-z=GO|mE+i-*P zd)4`=L_>LgJ=lC$O-4qE!Wfwm&2+ROHaM`}w&mUPGB&)3g!s_uBCjU$@K{|Ie| zaaIOw)PDf*V^GQfQ&}CL4jzWK$K3WLpbb5Bjchy}Z4^-eRTX|EcgQ&b7EMI*yJMZ4 zT_EntfWLSl=i@(*r2zbYL5PmZ0O%hxQJ{}JtCc>&4R`haG$032!;EN%vn7&{1T@*-^^d?J?aqq;iSl^dg;pXXBM0ki z8)<0awP`i7WxeL8p|7Z+DInJ=pz9=)b}65A(+&GxiN~Yz-t$z@WGb~paojM#rtOMe zEuB{F1+~Qe-%1oJ^{KlDxqAmRu+*yE19|Un^W@O7L<|ETpaoS4)C(5dO`IY<jPQbs+T?vEHo}4y!MrLza;Je zHeg{F(&I`IzZYwzLcDoDqbRrh_>Jjm zXjOh{>bA9-#SHV2G>gR~`RLA=l=V+uschw+g%r#Sz5?L618Za2#Z`{5&G1n zyPy@u)~T!2ePgE!&}qr}K)h*sq_bNW(ema*(47OC#<%gak;ca-`qwX7g&w<@LvLNz zG*ZTV4F4+8_p;?rBB1g4X7U2L&HznN<&*~JG46V`P}P7bCxu2cZOQ;UdD-1zW>K%4 z(+AZLCPm^xA;dd58@zd<#*4mz49oP{qboJdC}00oTaUEb!}nekRG)F_)8qEgS7E>k z^JUb_yQ!(+9QR%L*&mK=j#<)BG75_%kRUiAje9~7GfKK%vrtt{?gs~)9yhrfxlw)< zalW(?X!`CjyLSS;{l6E1vfcKg+Yu8Y#)I~^L>Vtg0IFfHt24#-Z7Y**jP@^9-ZL4A zu;*4M4Mb;0Llj@8g)Nh+whMlqWgbUqd=DQjnsNZqZ|7zGEHx2o3!S3+c?x=k#+yhs z_evDmat(J5|!cSoz?7 zPT?^?$5}weQr|DLWrSSHFi?8nYe%2flQ6KWaY~EBH}p$Rs!9>br@OnXXS%cxsL$VK25H(v4ZXVI{xRBqkXKKj6-9Y92LTZZZ~Lp_u$OR!Xwo zu=F4OngHW#7Ul044EuJ(E5~@*YWnvk^AX-GtNGqleY8OPpa;UE-`Uw2|JC2%R^B=< z*=OZ#88+A@iDcWdo=7>Fg`9rd=ZeYdR=SEVoY+d7iHyXy|7|v}*|s6+bdey1r+u@a$N|i&h{p42+F9 zzs66YeKLrlk9Je&@cPr6Dbk$swRKiAGm@`6=C?);PkvC{cxRb%G7pfQ*B6@X*Usuw zLz@*dF&AI;aIVtM#W*_zJtUVgFuEj2FzbYM#Bbz1uV|19x~&zhsO9 z$y3|IYoMVv@aM&YlGH`0?C|zw_f56I*}D0k@755Hx4riUCXMUPf^F|V{dsN6_+l?ISZu)Izt4rQt;G}se`y)I}F0Wg0>C)T2V$m3%Ibxn5gj)C{_Zyna84P&`zV)W& z@N7QWiUCZTkZ0~se)CpJdyQm#ub3QJy2-;gaKmNyy|h_psFk>t3Wx~xMYkK~-dq#X z&ds6?5Gb(JE=dep?;A_Q-hV9qAiqw0tvB2FvrCa?s)(}ocd_cE0JwNhw_SPR&5ATu zl9Ptq6?{a8fVQAQc;^d(^ydrF3_p3~Q|EH9J; zvinT(Uglm*o=eR_mLof7VRKBhKALxQ$g#l)QD+50<$!p=9d$qu<+d8#DU-Q@fx54b+1=y;FAX>4n!X@-TFT>#p|b99p{Wx!qSgn~n&n#dx8>YAwEC!5u;7izwG6f=-_0>Pu@Nn&=T5dFI(KX?-Pr0<;pWM71QP1-_^TTNaV(8MD z!TU{%z3s+B^6Nuq*;1o(#}{Ymt~QIFHt*mEPdduU6(zm1kIEYT)~TJRy)NEAOE27b zSsa_GVAi$aC(A?W&{|wG~n*`Ww@*@6t4fslOiY*H{hO`Zay2@1h5nJ!3`zQ zxG|sFfB4iFNrJqaUztzpRb74@JG3YeDZIIP^7279#T!xg8?BFwA5v#BvUfSLF!eBp zCm7xn`>4>sjx~=;O8>oPGQKikOx8Ovfa6=CxIgi>*okUd^IodknjOa)1r<`{))!Yy z`8X?iQ7n%uVSjyn{qU0Jn&p^!q*y;!h)qi99Ly<=zHE7BR`AH-VldR`E10VKr)8t& z`MYcWgUpO+w^fy+!Yo@G-IoJe+soU!Yj39oR=K9~9+TZ7J}8>qZ5ndD$hQ)FZNmW^ zDu?d&nxk#4ktBm)k~L#|go~J`YPrRgtGvzBi+!5>-AVj=x^D z7tpU}Tz4wV=fB)$Im7m;JN3q6;qa*Jaw7e5x|h4V+1x-z zxB}e@AkP$Qx>fjo0Xdty{yW5arP@tjLd?um&+D#Vn*Z!Ci9$Cvv#_D+l}N0ripqBB zh47OXUZ!%_b*V{oujsGe<}H|Ep|dcfgUR@gx^2ds@TpxZ&hW}gy83J^^m)+R)hdZP zpFp2PCR3hkRaaf>Rht&eD!TSzBg#znH06b<uvK%GOk^81>CIxwJ=T%2JQ6)wE{p z%c{Y^_q>>n3QFt+mP>B&XHAs6@Ofw5=w@Wlo%$k|mTI%%=eLnPg!9v|#|l5{{YO(0 zUVStmcde(Enp17v#gk?c3fj^*xQy}M#laoX?3&C ziE>bV`_)#^^D?M8CKuss5O~w;?mRjFEZW;7$}M&lW@;`6R8Tn%4zzMp_kmWyjI!2S zPUxf!FCn-gpR3XrkA_YnxsZhbw-|GkpvaTqZOCry zslo0#AMn#kQ-k@M7%cSo9f!r;m*}L&Y>1*YdW?C(xeBfpcOgr)!ruUpYJv9QmmZcu zaa`?arhd)igD|Mese6#l2lXWj*G3oY63r^r{sqL?849tt`rx)-{3{rzb=Z@&w+OEE zqxVn8TG`8A2INp#z>se{`ZJR1<#seEOS0PXEPTF^ws;;t@;*%1h`7ZMCHXRXRv;bL zErU6P=W19S&kR>N-|878Ui-~RcHzjx@)G@hvjnjXg(3TnO^5SuQ7L^G2L+ld2;AJj zx4v0U+d2>Q0xJEE4jf1qmb@;r8ROB%2KwqpyQkr|D1LJM$_9fzkRYn|O2^`_ZppQsnaL|dDmzR&6`KLGjI-&MDXlk> zu3L;^X+8-lDtYh*AQCDRoG%asA>_)FStkA*F=o{8?_#;%jRqoG2iz=pE{i|}$|(JN znuP@(?*R{fK+_v)!jht*?L1NT@m=wfo|7r1K}l1e)>gZ2G!O64-Eequ@K`fw>(|Y@ zpQ56U7F#5DqEqaScj0Q|3*vgV+=@e`r!U!V-}#=iK+&w~4*WoFigM)Tk_47A4_4%% zrTkV5dyT$B0{Y|fHr+9V(6QV7A&UC!x;K-*pv$i&v_@a$9HddI^0yI>9TK+E37V6g zBEJKSs0_Y}0bFf}|1ruPOfBKD&p6$ zS8G?(gW+)bryB}Exacpoa_3GJ0r@5%PkMQZyRP`n_O?RRXwbYmv%u2i^DJ}jL?R;O zwcr^lKUSGlfAuq2;8bijJcr4H`7I}lL_tTf*j9~eQc@LV`Oa`)>M(Pg(4M{VOO=S6 zJKYT8*vd4@o*Cxt0qqISh+NXsXgnZvIzZhH}Mv?)v%^-g_eVd-x}WAjf}PFgxna zNorr`tF5a~_Tf>T?q586NV@eQX~J^))FEug`O>_T;+}G1e}5@|xm5q9dEJ4}XJU}o z1L0hhs>@vZC|N#m?Xmxwyu6EtqH4$c`a{!zT={3zI>(QCmlv(&rR(HbTJuwpo%`cA zD^4^?$nNG#>R$5oFL4hAVnOw({Pn*pLO^Gyq$8T4A7d_OyLVmBmJ)byVa(%C$RBo_ V6j?y;m;U+ljnL4Am#9DV|1Z${{{{d6 literal 0 HcmV?d00001 diff --git a/docs/assets/img/icons/website/nhentai.svg b/docs/assets/img/icons/website/nhentai.svg new file mode 100644 index 00000000..48bd659f --- /dev/null +++ b/docs/assets/img/icons/website/nhentai.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/feat/index.md b/docs/feat/index.md index 7cc5dcb0..40b51acb 100644 --- a/docs/feat/index.md +++ b/docs/feat/index.md @@ -5,13 +5,18 @@ ## 适用性 -> [!Info] 没列出的功能全网适用,一眼独占的工具如 hitomiTool 也不会列出 +::: info 没列出的功能全网适用,一眼独占的工具如 hitomiTool 也不会列出 +表漫站点没以下矩阵功能 +::: -| | [拷贝](https://www.2026copy.com/) | [Māngabz](https://mangabz.com) | [禁漫](https://18comic.vip/) | [wnacg](https://www.wnacg.com/) | [ExHentai](https://exhentai.org/) | [hitomi](https://hitomi.la/) | [hcomic](https://h-comic.com) | -|:--------------------------------------|:-------------:|:---------:|:----:|:----------:|:----------:|:----------:|:----------:| -| 📋读剪贴板 | ❌ | ❌ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| 🔎聚合搜索 | ❌ | ❌ | ✔️ | ✔️ | ✔️ | 🚧 | ✔️ | -| 以图搜索 | ❌ | ❌ | ✔️ | ✔️ | ✔️ | 🚧 | ✔️ | +| | 📋读剪贴板 | 🔎聚合搜索 | 以图搜索 | +|:---|:---:|:---:|:---:| +| [禁漫](https://18comic.vip/) | ✔️ | ✔️ | ✔️ | +| [wnacg](https://www.wnacg.com/) | ✔️ | ✔️ | ✔️ | +| [ExHentai](https://exhentai.org/) | ✔️ | ✔️ | ✔️ | +| [hitomi](https://hitomi.la/) | 🚧 | 🚧 | 🚧 | +| [hcomic](https://h-comic.com) | ❌ | ✔️ | ✔️ | +| [nhentai](https://nhentai.net) | ❌ | ✔️ | ✔️ | ## 1. 主界面 diff --git a/docs/index.md b/docs/index.md index 1fe5e698..514f848e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,20 @@ hero: logo + + + + + + 站点状态 From dd370b6c74ca9967beeb663198408591d0027a03 Mon Sep 17 00:00:00 2001 From: json Date: Mon, 11 May 2026 18:40:55 +0800 Subject: [PATCH 09/11] feat: monitor add ISP status --- .../components/monitor/MonitorStatusBoard.vue | 265 +++++++++++++++--- .../components/monitor/monitorBoardApi.ts | 46 ++- .../monitor/monitorStatusBoardSource.ts | 113 +++++++- docs/assets/img/icons/isp/mobile.png | Bin 0 -> 4737 bytes docs/assets/img/icons/isp/telecom.png | Bin 0 -> 2147 bytes docs/assets/img/icons/isp/unicom.png | Bin 0 -> 2149 bytes 6 files changed, 373 insertions(+), 51 deletions(-) create mode 100644 docs/assets/img/icons/isp/mobile.png create mode 100644 docs/assets/img/icons/isp/telecom.png create mode 100644 docs/assets/img/icons/isp/unicom.png diff --git a/docs/.vitepress/components/monitor/MonitorStatusBoard.vue b/docs/.vitepress/components/monitor/MonitorStatusBoard.vue index b5043464..f3dc4317 100644 --- a/docs/.vitepress/components/monitor/MonitorStatusBoard.vue +++ b/docs/.vitepress/components/monitor/MonitorStatusBoard.vue @@ -3,13 +3,20 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { createEmptyMonitorBoardRuntimeData, emptyMonitorBoardLiveStatus, + getMonitorBoardVoteMatrixKey, + monitorBoardIsps, monitorBoardCopy, + monitorBoardVoteKeys, + sumMonitorBoardVoteMatrixByVote, type MonitorBoardLocale, type MonitorBoardRuntimeData, monitorBoardSites, + type MonitorBoardIsp, + type MonitorBoardIspKey, type MonitorBoardLiveStatus, type MonitorBoardStatusMap, type MonitorBoardUptimes, + type MonitorBoardVoteMatrix, type MonitorBoardVoteKey, type MonitorBoardVotes, } from './monitorStatusBoardSource' @@ -37,11 +44,17 @@ const props = withDefaults(defineProps<{ type MonitorBoardLocalStageEntry = { action: MonitorBoardVoteKey + state_isp: MonitorBoardIspKey completedAt: string } type MonitorBoardLocalStageMap = Partial> +type MonitorBoardPendingIspSelection = { + cardId: string + action: MonitorBoardVoteKey +} + type MonitorBoardLoadState = 'idle' | 'loading' | 'ready' | 'error' type MonitorBoardOrbitPosition = { @@ -76,7 +89,21 @@ type MonitorBoardSegment = { percent: number } +type MonitorBoardIspSignalCell = { + key: MonitorBoardVoteKey + value: number + color: string + glow: string + strength: number +} + +type MonitorBoardIspSignalRow = { + isp: MonitorBoardIsp + cells: MonitorBoardIspSignalCell[] +} + const MONITOR_VOTE_DISABLED_STORAGE_KEY = 'monitor-board-vote-disabled:v1' +const MONITOR_SELECTED_ISP_STORAGE_KEY = 'monitor-board-selected-isp:v1' const MONITOR_CHART_MIN_VALUE = 0 const MONITOR_CHART_LAYER_OFFSETS = [20, 40, 60] as const const MONITOR_CHART_EMA_DECAY = 0.75 @@ -114,8 +141,9 @@ function buildChartLines(cumulativeUptimes: MonitorBoardUptimes): MonitorBoardCh return [] } - const len = cumulativeUptimes.length - 1 - let prev = cumulativeUptimes[0] + const cumulativeVotes = cumulativeUptimes.map((sample) => sumMonitorBoardVoteMatrixByVote(sample)) + const len = cumulativeVotes.length - 1 + let prev = cumulativeVotes[0] let chartMax = 1 const emaSamples: MonitorBoardVotes[] = new Array(len) let emaUp = 0 @@ -123,7 +151,7 @@ function buildChartLines(cumulativeUptimes: MonitorBoardUptimes): MonitorBoardCh let emaDown = 0 for (let i = 0; i < len; i++) { - const curr = cumulativeUptimes[i + 1] + const curr = cumulativeVotes[i + 1] const diffUp = curr.up - prev.up const diffNeutral = curr.neutral - prev.neutral const diffDown = curr.down - prev.down @@ -173,11 +201,13 @@ function cloneStatusMap(statusMap: MonitorBoardStatusMap): Record monitorBoardCopy[props.locale]) const completedToastLabel = computed(() => props.locale === 'zh' ? '已投票' : 'Voted') const boardRoot = ref(null) +const selectedIsp = ref(null) const remoteRuntimeData = ref(null) const loadState = ref('idle') const loadErrorMessage = ref(null) const localStageMap = ref({}) const pendingStageMap = ref({}) +const pendingIspSelection = ref(null) const voteDisabledDetail = ref(null) const activeCardId = ref(null) const toastMessage = ref(null) @@ -212,6 +242,7 @@ const effectiveApiBaseUrl = computed(() => { }) const localStageStorageKey = computed(() => `monitor-board-local-stage:${resetStartedAt.value ?? 'default'}`) const hasActiveCard = computed(() => activeCardId.value !== null) +const hasIspFocus = computed(() => pendingIspSelection.value !== null) const hasPendingSubmission = computed(() => Object.keys(pendingStageMap.value).length > 0) const voteDisabledToastLabel = computed(() => ( voteDisabledDetail.value ? buildSubmitFailedToast(voteDisabledDetail.value) : null @@ -246,7 +277,7 @@ const MONITOR_PREVIEW_APEX_Y_PX = -1 const MONITOR_PREVIEW_REBOUND_Y_PX = 4 const MONITOR_PREVIEW_SETTLE_Y_PX = -1 const MONITOR_PREVIEW_END_Y_PX = 0 -const monitorVoteKeys: MonitorBoardVoteKey[] = ['up', 'neutral', 'down'] +const monitorVoteKeys = monitorBoardVoteKeys const monitorPreviewMotionStyle = { '--monitor-card-layer': `${MONITOR_CARD_LAYER}`, @@ -313,12 +344,42 @@ function buildVoteSegments(votes: MonitorBoardVotes): MonitorBoardSegment[] { }) } +function buildIspSignalRows(votes: MonitorBoardVoteMatrix): MonitorBoardIspSignalRow[] { + const maxValue = Math.max( + 1, + ...monitorBoardIsps.flatMap((isp) => ( + monitorVoteKeys.map((key) => votes[getMonitorBoardVoteMatrixKey(key, isp.id)]) + )), + ) + + return monitorBoardIsps.reduce((rows, isp) => { + const cells = monitorVoteKeys.map((key) => { + const meta = monitorVoteMetaMap[key] + const value = votes[getMonitorBoardVoteMatrixKey(key, isp.id)] + return { + key, + value, + color: meta.color, + glow: meta.glow, + strength: value === 0 ? 0 : Math.max(0.24, value / maxValue), + } + }) + const total = cells.reduce((sum, cell) => sum + cell.value, 0) + if (total > 0) rows.push({ isp, cells }) + return rows + }, []) +} + let toastTimer: number | null = null function isMonitorBoardVoteKey(value: unknown): value is MonitorBoardVoteKey { return value === 'up' || value === 'neutral' || value === 'down' } +function isMonitorBoardIspKey(value: unknown): value is MonitorBoardIspKey { + return value === 'telecom' || value === 'mobile' || value === 'unicom' +} + function readLocalStageMap(storageKey: string): MonitorBoardLocalStageMap { if (typeof window === 'undefined') { return {} @@ -342,12 +403,17 @@ function readLocalStageMap(storageKey: string): MonitorBoardLocalStageMap { } const action = (stage as { action?: unknown }).action + const state_isp = (stage as { state_isp?: unknown }).state_isp const completedAt = (stage as { completedAt?: unknown }).completedAt if (!isMonitorBoardVoteKey(action) || typeof completedAt !== 'string') { return [] } - return [[siteId, { action, completedAt }]] + return [[siteId, { + action, + state_isp: isMonitorBoardIspKey(state_isp) ? state_isp : 'telecom', + completedAt, + }]] }), ) as MonitorBoardLocalStageMap } catch (error) { @@ -364,6 +430,23 @@ function writeLocalStageMap(storageKey: string, stageMap: MonitorBoardLocalStage window.localStorage.setItem(storageKey, JSON.stringify(stageMap)) } +function readSelectedIsp(storageKey: string): MonitorBoardIspKey | null { + if (typeof window === 'undefined') { + return null + } + + const rawValue = window.localStorage.getItem(storageKey) + return isMonitorBoardIspKey(rawValue) ? rawValue : null +} + +function writeSelectedIsp(storageKey: string, _ispKey: MonitorBoardIspKey): void { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(storageKey, _ispKey) +} + function readVoteDisabledDetail(storageKey: string): string | null { if (typeof window === 'undefined') { return null @@ -385,6 +468,10 @@ function syncLocalStageMap(): void { localStageMap.value = readLocalStageMap(localStageStorageKey.value) } +function syncSelectedIsp(): void { + selectedIsp.value = readSelectedIsp(MONITOR_SELECTED_ISP_STORAGE_KEY) +} + function syncVoteDisabledDetail(): void { voteDisabledDetail.value = readVoteDisabledDetail(MONITOR_VOTE_DISABLED_STORAGE_KEY) } @@ -532,7 +619,7 @@ function handleCardKeydown(cardId: string, event: KeyboardEvent): void { handleCardActivate(cardId) } -function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { +function submitVote(cardId: string, action: MonitorBoardVoteKey, _stateIsp: MonitorBoardIspKey): void { if (voteDisabledToastLabel.value) { clearActiveCard() showToast(voteDisabledToastLabel.value) @@ -541,6 +628,7 @@ function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { const stageEntry: MonitorBoardLocalStageEntry = { action, + state_isp: _stateIsp, completedAt: new Date().toISOString(), } @@ -550,6 +638,7 @@ function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { void submitMonitorBoardVote({ siteId: cardId, action, + state_isp: _stateIsp, delta: 1, }, effectiveApiBaseUrl.value) .then(() => { @@ -557,6 +646,7 @@ function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { const currentRuntimeData = remoteRuntimeData.value ?? createEmptyMonitorBoardRuntimeData(resetDate.value, resetStartedAt.value) const currentLiveStatus = currentRuntimeData.statusMap[cardId] ?? emptyMonitorBoardLiveStatus + const matrixKey = getMonitorBoardVoteMatrixKey(action, _stateIsp) remoteRuntimeData.value = { resetDate: currentRuntimeData.resetDate, @@ -567,7 +657,7 @@ function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { uptimes: currentLiveStatus.uptimes.map((sample) => ({ ...sample })), votes: { ...currentLiveStatus.votes, - [action]: currentLiveStatus.votes[action] + 1, + [matrixKey]: currentLiveStatus.votes[matrixKey] + 1, }, }, }, @@ -591,8 +681,45 @@ function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { }) } +function closeIspFocus(): void { + pendingIspSelection.value = null +} + +function handleIspFocusDismiss(): void { + closeIspFocus() + clearActiveCard() +} + +function handleIspSelect(_ispKey: MonitorBoardIspKey): void { + const pendingVote = pendingIspSelection.value + if (!pendingVote) { + return + } + + selectedIsp.value = _ispKey + writeSelectedIsp(MONITOR_SELECTED_ISP_STORAGE_KEY, _ispKey) + closeIspFocus() + submitVote(pendingVote.cardId, pendingVote.action, _ispKey) +} + +function handleBubbleVote(cardId: string, action: MonitorBoardVoteKey): void { + if (voteDisabledToastLabel.value) { + clearActiveCard() + showToast(voteDisabledToastLabel.value) + return + } + + const _selectedIsp = selectedIsp.value + if (!_selectedIsp) { + pendingIspSelection.value = { cardId, action } + return + } + + submitVote(cardId, action, _selectedIsp) +} + function handleDocumentPointerDown(event: PointerEvent): void { - if (!activeCardId.value || !boardRoot.value) { + if (hasIspFocus.value || !activeCardId.value || !boardRoot.value) { return } @@ -611,12 +738,17 @@ function handleDocumentPointerDown(event: PointerEvent): void { function handleDocumentKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { + if (hasIspFocus.value) { + handleIspFocusDismiss() + return + } clearActiveCard() } } onMounted(() => { syncLocalStageMap() + syncSelectedIsp() syncVoteDisabledDetail() document.addEventListener('pointerdown', handleDocumentPointerDown) document.addEventListener('keydown', handleDocumentKeydown) @@ -638,6 +770,7 @@ type MonitorBoardCardWithChart = { chartLines: MonitorBoardChartLine[] isCompleted: boolean completedBorderColor: string + ispSignalRows: MonitorBoardIspSignalRow[] segments: MonitorBoardSegment[] } & typeof monitorBoardSites[number] @@ -650,21 +783,20 @@ const cardsWithCharts = computed(() => { const liveStatus: MonitorBoardLiveStatus = statusMap[site.id] ?? emptyMonitorBoardLiveStatus const stagedVote = displayStage[site.id] const pendingVote = pendingStage[site.id] - const effectiveVotes: MonitorBoardVotes = { - up: liveStatus.votes.up, - neutral: liveStatus.votes.neutral, - down: liveStatus.votes.down, - } + const effectiveVoteMatrix: MonitorBoardVoteMatrix = { ...liveStatus.votes } if (pendingVote) { - effectiveVotes[pendingVote.action] += 1 + const matrixKey = getMonitorBoardVoteMatrixKey(pendingVote.action, pendingVote.state_isp) + effectiveVoteMatrix[matrixKey] += 1 } + const effectiveVotes = sumMonitorBoardVoteMatrixByVote(effectiveVoteMatrix) return { ...site, chartLines: buildChartLines(liveStatus.uptimes), isCompleted: stagedVote != null, completedBorderColor: stagedVote ? monitorVoteMetaMap[stagedVote.action].color : 'transparent', + ispSignalRows: buildIspSignalRows(effectiveVoteMatrix), segments: buildVoteSegments(effectiveVotes), } }) @@ -673,7 +805,7 @@ const cardsWithCharts = computed(() => {