novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/__init__.py +1 -3
- novel_downloader/cli/clean.py +21 -88
- novel_downloader/cli/config.py +26 -21
- novel_downloader/cli/download.py +79 -66
- novel_downloader/cli/export.py +17 -21
- novel_downloader/cli/main.py +1 -1
- novel_downloader/cli/search.py +62 -65
- novel_downloader/cli/ui.py +156 -0
- novel_downloader/config/__init__.py +8 -5
- novel_downloader/config/adapter.py +206 -209
- novel_downloader/config/{loader.py → file_io.py} +53 -26
- novel_downloader/core/__init__.py +5 -5
- novel_downloader/core/archived/deqixs/fetcher.py +115 -0
- novel_downloader/core/archived/deqixs/parser.py +132 -0
- novel_downloader/core/archived/deqixs/searcher.py +89 -0
- novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
- novel_downloader/core/archived/wanbengo/searcher.py +98 -0
- novel_downloader/core/archived/xshbook/searcher.py +93 -0
- novel_downloader/core/downloaders/__init__.py +3 -24
- novel_downloader/core/downloaders/base.py +49 -23
- novel_downloader/core/downloaders/common.py +191 -137
- novel_downloader/core/downloaders/qianbi.py +187 -146
- novel_downloader/core/downloaders/qidian.py +187 -141
- novel_downloader/core/downloaders/registry.py +4 -2
- novel_downloader/core/downloaders/signals.py +46 -0
- novel_downloader/core/exporters/__init__.py +3 -20
- novel_downloader/core/exporters/base.py +33 -37
- novel_downloader/core/exporters/common/__init__.py +1 -2
- novel_downloader/core/exporters/common/epub.py +15 -10
- novel_downloader/core/exporters/common/main_exporter.py +19 -12
- novel_downloader/core/exporters/common/txt.py +17 -12
- novel_downloader/core/exporters/epub_util.py +59 -29
- novel_downloader/core/exporters/linovelib/__init__.py +1 -0
- novel_downloader/core/exporters/linovelib/epub.py +23 -25
- novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
- novel_downloader/core/exporters/linovelib/txt.py +20 -14
- novel_downloader/core/exporters/qidian.py +2 -8
- novel_downloader/core/exporters/registry.py +4 -2
- novel_downloader/core/exporters/txt_util.py +7 -7
- novel_downloader/core/fetchers/__init__.py +54 -48
- novel_downloader/core/fetchers/aaatxt.py +83 -0
- novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
- novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
- novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
- novel_downloader/core/fetchers/dxmwx.py +110 -0
- novel_downloader/core/fetchers/eightnovel.py +139 -0
- novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
- novel_downloader/core/fetchers/guidaye.py +85 -0
- novel_downloader/core/fetchers/hetushu.py +92 -0
- novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
- novel_downloader/core/fetchers/ixdzs8.py +113 -0
- novel_downloader/core/fetchers/jpxs123.py +101 -0
- novel_downloader/core/fetchers/lewenn.py +83 -0
- novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
- novel_downloader/core/fetchers/piaotia.py +105 -0
- novel_downloader/core/fetchers/qbtr.py +101 -0
- novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
- novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
- novel_downloader/core/fetchers/quanben5.py +92 -0
- novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
- novel_downloader/core/fetchers/registry.py +5 -16
- novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
- novel_downloader/core/fetchers/shencou.py +106 -0
- novel_downloader/core/fetchers/shuhaige.py +84 -0
- novel_downloader/core/fetchers/tongrenquan.py +84 -0
- novel_downloader/core/fetchers/ttkan.py +95 -0
- novel_downloader/core/fetchers/wanbengo.py +83 -0
- novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
- novel_downloader/core/fetchers/xiguashuwu.py +177 -0
- novel_downloader/core/fetchers/xs63b.py +171 -0
- novel_downloader/core/fetchers/xshbook.py +85 -0
- novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
- novel_downloader/core/fetchers/yibige.py +114 -0
- novel_downloader/core/interfaces/__init__.py +1 -9
- novel_downloader/core/interfaces/downloader.py +6 -2
- novel_downloader/core/interfaces/exporter.py +7 -7
- novel_downloader/core/interfaces/fetcher.py +6 -19
- novel_downloader/core/interfaces/parser.py +7 -8
- novel_downloader/core/interfaces/searcher.py +9 -1
- novel_downloader/core/parsers/__init__.py +49 -12
- novel_downloader/core/parsers/aaatxt.py +132 -0
- novel_downloader/core/parsers/b520.py +116 -0
- novel_downloader/core/parsers/base.py +64 -12
- novel_downloader/core/parsers/biquyuedu.py +133 -0
- novel_downloader/core/parsers/dxmwx.py +162 -0
- novel_downloader/core/parsers/eightnovel.py +224 -0
- novel_downloader/core/parsers/esjzone.py +64 -69
- novel_downloader/core/parsers/guidaye.py +128 -0
- novel_downloader/core/parsers/hetushu.py +139 -0
- novel_downloader/core/parsers/i25zw.py +137 -0
- novel_downloader/core/parsers/ixdzs8.py +186 -0
- novel_downloader/core/parsers/jpxs123.py +137 -0
- novel_downloader/core/parsers/lewenn.py +142 -0
- novel_downloader/core/parsers/linovelib.py +48 -64
- novel_downloader/core/parsers/piaotia.py +189 -0
- novel_downloader/core/parsers/qbtr.py +136 -0
- novel_downloader/core/parsers/qianbi.py +48 -50
- novel_downloader/core/parsers/qidian/main_parser.py +756 -48
- novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
- novel_downloader/core/parsers/quanben5.py +103 -0
- novel_downloader/core/parsers/registry.py +5 -16
- novel_downloader/core/parsers/sfacg.py +38 -45
- novel_downloader/core/parsers/shencou.py +215 -0
- novel_downloader/core/parsers/shuhaige.py +111 -0
- novel_downloader/core/parsers/tongrenquan.py +116 -0
- novel_downloader/core/parsers/ttkan.py +132 -0
- novel_downloader/core/parsers/wanbengo.py +191 -0
- novel_downloader/core/parsers/xiaoshuowu.py +173 -0
- novel_downloader/core/parsers/xiguashuwu.py +429 -0
- novel_downloader/core/parsers/xs63b.py +161 -0
- novel_downloader/core/parsers/xshbook.py +134 -0
- novel_downloader/core/parsers/yamibo.py +87 -131
- novel_downloader/core/parsers/yibige.py +166 -0
- novel_downloader/core/searchers/__init__.py +34 -3
- novel_downloader/core/searchers/aaatxt.py +107 -0
- novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
- novel_downloader/core/searchers/base.py +112 -36
- novel_downloader/core/searchers/dxmwx.py +105 -0
- novel_downloader/core/searchers/eightnovel.py +84 -0
- novel_downloader/core/searchers/esjzone.py +43 -25
- novel_downloader/core/searchers/hetushu.py +92 -0
- novel_downloader/core/searchers/i25zw.py +93 -0
- novel_downloader/core/searchers/ixdzs8.py +107 -0
- novel_downloader/core/searchers/jpxs123.py +107 -0
- novel_downloader/core/searchers/piaotia.py +100 -0
- novel_downloader/core/searchers/qbtr.py +106 -0
- novel_downloader/core/searchers/qianbi.py +74 -40
- novel_downloader/core/searchers/quanben5.py +144 -0
- novel_downloader/core/searchers/registry.py +24 -8
- novel_downloader/core/searchers/shuhaige.py +124 -0
- novel_downloader/core/searchers/tongrenquan.py +110 -0
- novel_downloader/core/searchers/ttkan.py +92 -0
- novel_downloader/core/searchers/xiaoshuowu.py +122 -0
- novel_downloader/core/searchers/xiguashuwu.py +95 -0
- novel_downloader/core/searchers/xs63b.py +104 -0
- novel_downloader/locales/en.json +34 -85
- novel_downloader/locales/zh.json +35 -86
- novel_downloader/models/__init__.py +21 -22
- novel_downloader/models/book.py +44 -0
- novel_downloader/models/config.py +4 -37
- novel_downloader/models/login.py +1 -1
- novel_downloader/models/search.py +5 -0
- novel_downloader/resources/config/settings.toml +8 -70
- novel_downloader/resources/json/xiguashuwu.json +718 -0
- novel_downloader/utils/__init__.py +13 -24
- novel_downloader/utils/chapter_storage.py +5 -5
- novel_downloader/utils/constants.py +4 -31
- novel_downloader/utils/cookies.py +38 -35
- novel_downloader/utils/crypto_utils/__init__.py +7 -0
- novel_downloader/utils/crypto_utils/aes_util.py +90 -0
- novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
- novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
- novel_downloader/utils/crypto_utils/rc4.py +54 -0
- novel_downloader/utils/epub/__init__.py +3 -4
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +62 -21
- novel_downloader/utils/epub/documents.py +95 -201
- novel_downloader/utils/epub/models.py +8 -22
- novel_downloader/utils/epub/utils.py +73 -106
- novel_downloader/utils/file_utils/__init__.py +2 -23
- novel_downloader/utils/file_utils/io.py +53 -188
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -15
- novel_downloader/utils/fontocr/__init__.py +5 -14
- novel_downloader/utils/fontocr/core.py +216 -0
- novel_downloader/utils/fontocr/loader.py +50 -0
- novel_downloader/utils/logger.py +81 -65
- novel_downloader/utils/network.py +17 -41
- novel_downloader/utils/state.py +4 -90
- novel_downloader/utils/text_utils/__init__.py +1 -7
- novel_downloader/utils/text_utils/diff_display.py +5 -7
- novel_downloader/utils/text_utils/text_cleaner.py +39 -30
- novel_downloader/utils/text_utils/truncate_utils.py +3 -14
- novel_downloader/utils/time_utils/__init__.py +5 -11
- novel_downloader/utils/time_utils/datetime_utils.py +20 -29
- novel_downloader/utils/time_utils/sleep_utils.py +55 -49
- novel_downloader/web/__init__.py +13 -0
- novel_downloader/web/components/__init__.py +11 -0
- novel_downloader/web/components/navigation.py +35 -0
- novel_downloader/web/main.py +66 -0
- novel_downloader/web/pages/__init__.py +17 -0
- novel_downloader/web/pages/download.py +78 -0
- novel_downloader/web/pages/progress.py +147 -0
- novel_downloader/web/pages/search.py +329 -0
- novel_downloader/web/services/__init__.py +17 -0
- novel_downloader/web/services/client_dialog.py +164 -0
- novel_downloader/web/services/cred_broker.py +113 -0
- novel_downloader/web/services/cred_models.py +35 -0
- novel_downloader/web/services/task_manager.py +264 -0
- novel_downloader-2.0.1.dist-info/METADATA +172 -0
- novel_downloader-2.0.1.dist-info/RECORD +206 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
- novel_downloader/core/downloaders/biquge.py +0 -29
- novel_downloader/core/downloaders/esjzone.py +0 -29
- novel_downloader/core/downloaders/linovelib.py +0 -29
- novel_downloader/core/downloaders/sfacg.py +0 -29
- novel_downloader/core/downloaders/yamibo.py +0 -29
- novel_downloader/core/exporters/biquge.py +0 -22
- novel_downloader/core/exporters/esjzone.py +0 -22
- novel_downloader/core/exporters/qianbi.py +0 -22
- novel_downloader/core/exporters/sfacg.py +0 -22
- novel_downloader/core/exporters/yamibo.py +0 -22
- novel_downloader/core/fetchers/base/__init__.py +0 -14
- novel_downloader/core/fetchers/base/browser.py +0 -422
- novel_downloader/core/fetchers/biquge/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/browser.py +0 -209
- novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
- novel_downloader/core/fetchers/linovelib/browser.py +0 -198
- novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/browser.py +0 -326
- novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
- novel_downloader/core/fetchers/sfacg/browser.py +0 -194
- novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
- novel_downloader/core/fetchers/yamibo/browser.py +0 -234
- novel_downloader/core/parsers/biquge.py +0 -139
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
- novel_downloader/models/chapter.py +0 -25
- novel_downloader/models/types.py +0 -13
- novel_downloader/tui/__init__.py +0 -7
- novel_downloader/tui/app.py +0 -32
- novel_downloader/tui/main.py +0 -17
- novel_downloader/tui/screens/__init__.py +0 -14
- novel_downloader/tui/screens/home.py +0 -198
- novel_downloader/tui/screens/login.py +0 -74
- novel_downloader/tui/styles/home_layout.tcss +0 -79
- novel_downloader/tui/widgets/richlog_handler.py +0 -24
- novel_downloader/utils/cache.py +0 -24
- novel_downloader/utils/crypto_utils.py +0 -71
- novel_downloader/utils/fontocr/hash_store.py +0 -280
- novel_downloader/utils/fontocr/hash_utils.py +0 -103
- novel_downloader/utils/fontocr/model_loader.py +0 -69
- novel_downloader/utils/fontocr/ocr_v1.py +0 -315
- novel_downloader/utils/fontocr/ocr_v2.py +0 -764
- novel_downloader/utils/fontocr/ocr_v3.py +0 -744
- novel_downloader-1.5.0.dist-info/METADATA +0 -196
- novel_downloader-1.5.0.dist-info/RECORD +0 -164
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.main
|
4
|
+
-------------------------
|
5
|
+
|
6
|
+
Novel Downloader web UI (NiceGUI).
|
7
|
+
|
8
|
+
This entry point starts the local server and registers the app's pages.
|
9
|
+
"""
|
10
|
+
|
11
|
+
import argparse
|
12
|
+
from pathlib import Path
|
13
|
+
|
14
|
+
from nicegui import app, ui
|
15
|
+
|
16
|
+
import novel_downloader.web.pages # noqa: F401
|
17
|
+
from novel_downloader.config import get_config_value
|
18
|
+
from novel_downloader.utils.logger import setup_logging
|
19
|
+
|
20
|
+
|
21
|
+
def mount_exports() -> None:
|
22
|
+
output_dir = get_config_value(["general", "output_dir"], "./downloads")
|
23
|
+
out = Path(output_dir).expanduser().resolve()
|
24
|
+
out.mkdir(parents=True, exist_ok=True)
|
25
|
+
# serves /download/<filename> from the export dir
|
26
|
+
app.add_static_files("/download", local_directory=out)
|
27
|
+
|
28
|
+
|
29
|
+
def web_main() -> None:
|
30
|
+
p = argparse.ArgumentParser(
|
31
|
+
description="Novel Downloader web UI.",
|
32
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
33
|
+
)
|
34
|
+
p.add_argument(
|
35
|
+
"--listen",
|
36
|
+
choices=["local", "public"],
|
37
|
+
default="local",
|
38
|
+
help=(
|
39
|
+
"Bind address mode: 'local' binds to 127.0.0.1; "
|
40
|
+
"'public' binds to 0.0.0.0."
|
41
|
+
),
|
42
|
+
)
|
43
|
+
p.add_argument(
|
44
|
+
"--port",
|
45
|
+
type=int,
|
46
|
+
default=8080,
|
47
|
+
help="TCP port to serve the app on.",
|
48
|
+
)
|
49
|
+
p.add_argument(
|
50
|
+
"--reload",
|
51
|
+
action="store_true",
|
52
|
+
help="Enable autoreload on code changes (development).",
|
53
|
+
)
|
54
|
+
args = p.parse_args()
|
55
|
+
|
56
|
+
host = "127.0.0.1" if args.listen == "local" else "0.0.0.0"
|
57
|
+
|
58
|
+
log_level = get_config_value(["general", "debug", "log_level"], "INFO")
|
59
|
+
setup_logging(console_level=log_level)
|
60
|
+
|
61
|
+
app.on_startup(mount_exports)
|
62
|
+
ui.run(host=host, port=args.port, reload=args.reload)
|
63
|
+
|
64
|
+
|
65
|
+
if __name__ in {"__main__", "__mp_main__"}:
|
66
|
+
web_main()
|
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.pages
|
4
|
+
--------------------------
|
5
|
+
|
6
|
+
NiceGUI page registrations; importing this package exposes and registers all routes.
|
7
|
+
"""
|
8
|
+
|
9
|
+
__all__ = [
|
10
|
+
"page_download", # /download
|
11
|
+
"page_progress", # /progress
|
12
|
+
"page_search", # /
|
13
|
+
]
|
14
|
+
|
15
|
+
from .download import page_download
|
16
|
+
from .progress import page_progress
|
17
|
+
from .search import page_search
|
@@ -0,0 +1,78 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.pages.download
|
4
|
+
-----------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from nicegui import ui
|
9
|
+
|
10
|
+
from novel_downloader.web.components import navbar
|
11
|
+
from novel_downloader.web.services import manager, setup_dialog
|
12
|
+
|
13
|
+
_SUPPORT_SITES = {
|
14
|
+
"aaatxt": "3A电子书 (aaatxt)",
|
15
|
+
"biquge": "笔趣阁 (biquge)",
|
16
|
+
"biquyuedu": "精彩小说 (biquyuedu)",
|
17
|
+
"dxmwx": "大熊猫文学网 (dxmwx)",
|
18
|
+
"eightnovel": "无限轻小说 (8novel)",
|
19
|
+
"esjzone": "ESJ Zone (esjzone)",
|
20
|
+
"guidaye": "名著阅读 (guidaye)",
|
21
|
+
"hetushu": "和图书 (hetushu)",
|
22
|
+
"i25zw": "25中文网 (i25zw)",
|
23
|
+
"ixdzs8": "爱下电子书 (ixdzs8)",
|
24
|
+
"jpxs123": "精品小说网 (jpxs123)",
|
25
|
+
"lewenn": "乐文小说网 (lewenn)",
|
26
|
+
"linovelib": "哔哩轻小说 (linovelib)",
|
27
|
+
"piaotia": "飘天文学网 (piaotia)",
|
28
|
+
"qbtr": "全本同人小说 (qbtr)",
|
29
|
+
"qianbi": "铅笔小说 (qianbi)",
|
30
|
+
"qidian": "起点中文网 (qidian)",
|
31
|
+
"quanben5": "全本小说网 (quanben5)",
|
32
|
+
"sfacg": "SF轻小说 (sfacg)",
|
33
|
+
"shencou": "神凑轻小说 (shencou)",
|
34
|
+
"shuhaige": "书海阁小说网 (shuhaige)",
|
35
|
+
"tongrenquan": "同人圈 (tongrenquan)",
|
36
|
+
"ttkan": "天天看小说 (ttkan)",
|
37
|
+
"wanbengo": "完本神站 (wanbengo)",
|
38
|
+
"xiaoshuowu": "小说屋 (xiaoshuowu)",
|
39
|
+
"xiguashuwu": "西瓜书屋 (xiguashuwu)",
|
40
|
+
"xs63b": "小说路上 (xs63b)",
|
41
|
+
"xshbook": "小说虎 (xshbook)",
|
42
|
+
"yamibo": "百合会 (yamibo)",
|
43
|
+
"yibige": "一笔阁 (yibige)",
|
44
|
+
}
|
45
|
+
_DEFAULT_SITE = "qidian"
|
46
|
+
|
47
|
+
|
48
|
+
@ui.page("/download") # type: ignore[misc]
|
49
|
+
def page_download() -> None:
|
50
|
+
navbar("download")
|
51
|
+
ui.label("下载界面").classes("text-lg")
|
52
|
+
setup_dialog()
|
53
|
+
|
54
|
+
with ui.card().classes("max-w-[600px]"):
|
55
|
+
site = ui.select(
|
56
|
+
_SUPPORT_SITES,
|
57
|
+
value=_DEFAULT_SITE,
|
58
|
+
label="站点",
|
59
|
+
with_input=True,
|
60
|
+
).classes("w-full")
|
61
|
+
|
62
|
+
book_id = ui.input("书籍ID").props("outlined dense").classes("w-full")
|
63
|
+
|
64
|
+
async def add_task() -> None:
|
65
|
+
bid = (book_id.value or "").strip()
|
66
|
+
if not bid:
|
67
|
+
ui.notify("请输入书籍ID", type="warning")
|
68
|
+
return
|
69
|
+
title = f"{site.value} (id = {bid})"
|
70
|
+
ui.notify(f"已添加任务: {title}")
|
71
|
+
await manager.add_task(title=title, site=str(site.value), book_id=bid)
|
72
|
+
|
73
|
+
with ui.row().classes("justify-end w-full"):
|
74
|
+
ui.button(
|
75
|
+
"添加到下载队列",
|
76
|
+
on_click=add_task,
|
77
|
+
color="primary",
|
78
|
+
).props("unelevated")
|
@@ -0,0 +1,147 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.pages.progress
|
4
|
+
-----------------------------------
|
5
|
+
|
6
|
+
Layout for active/history tasks with compact cards and status chips.
|
7
|
+
"""
|
8
|
+
|
9
|
+
|
10
|
+
from nicegui import ui
|
11
|
+
|
12
|
+
from novel_downloader.web.components import navbar
|
13
|
+
from novel_downloader.web.services import DownloadTask, Status, manager, setup_dialog
|
14
|
+
|
15
|
+
|
16
|
+
def _status_chip(status: Status) -> None:
|
17
|
+
label_map = {
|
18
|
+
"queued": "已排队",
|
19
|
+
"running": "运行中",
|
20
|
+
"completed": "完成",
|
21
|
+
"cancelled": "已取消",
|
22
|
+
"failed": "失败",
|
23
|
+
}
|
24
|
+
color_map = {
|
25
|
+
"queued": "warning",
|
26
|
+
"running": "primary",
|
27
|
+
"completed": "positive",
|
28
|
+
"cancelled": "grey-6",
|
29
|
+
"failed": "negative",
|
30
|
+
}
|
31
|
+
ui.chip(label_map[status]).props(
|
32
|
+
f"outline color={color_map[status]} dense"
|
33
|
+
).classes("q-ml-sm")
|
34
|
+
|
35
|
+
|
36
|
+
def _meta_row(label: str, value: str) -> None:
|
37
|
+
with ui.row().classes("items-center justify-between text-xs text-grey-7 w-full"):
|
38
|
+
ui.label(label)
|
39
|
+
ui.label(value)
|
40
|
+
|
41
|
+
|
42
|
+
def _progress_block(t: DownloadTask) -> None:
|
43
|
+
# progress or summary depending on state
|
44
|
+
if t.status == "running":
|
45
|
+
if t.chapters_total <= 0:
|
46
|
+
label_text = f"{t.chapters_done}/? · 正在获取总章节..."
|
47
|
+
ui.linear_progress().props("indeterminate striped").classes("w-full")
|
48
|
+
ui.label(label_text).classes("text-xs text-grey-7")
|
49
|
+
else:
|
50
|
+
ui.linear_progress(value=t.progress()).props("instant-feedback").classes(
|
51
|
+
"w-full"
|
52
|
+
)
|
53
|
+
ui.label(f"{t.chapters_done}/{t.chapters_total} · running").classes(
|
54
|
+
"text-xs text-grey-7"
|
55
|
+
)
|
56
|
+
else:
|
57
|
+
suffix = {"completed": "完成", "cancelled": "已取消", "failed": "失败"}.get(
|
58
|
+
t.status, ""
|
59
|
+
)
|
60
|
+
if t.chapters_total > 0:
|
61
|
+
ui.label(f"{t.chapters_done}/{t.chapters_total} · {suffix}").classes(
|
62
|
+
"text-xs text-grey-7"
|
63
|
+
)
|
64
|
+
else:
|
65
|
+
ui.label(f"{t.chapters_done}/? · {suffix}").classes("text-xs text-grey-7")
|
66
|
+
|
67
|
+
if t.status == "completed" and t.exported_paths:
|
68
|
+
with ui.row().classes("w-full gap-2 mt-1"):
|
69
|
+
for key, p in t.exported_paths.items():
|
70
|
+
url = f"/download/{p.name}?v={t.task_id}"
|
71
|
+
ui.button(key, on_click=lambda e, url=url: ui.download(url)).props(
|
72
|
+
"outline size=sm"
|
73
|
+
)
|
74
|
+
|
75
|
+
|
76
|
+
def _task_card(t: DownloadTask, *, active: bool) -> None:
|
77
|
+
with ui.card().classes("w-full"):
|
78
|
+
# header
|
79
|
+
with ui.row().classes("items-center justify-between w-full"):
|
80
|
+
with ui.row().classes("items-center gap-2"):
|
81
|
+
ui.label(t.title).classes("text-sm font-medium")
|
82
|
+
_status_chip(t.status)
|
83
|
+
if active and t.status in ("running", "queued"):
|
84
|
+
|
85
|
+
async def cancel_this(tid: str = t.task_id) -> None:
|
86
|
+
ok = await manager.cancel_task(tid)
|
87
|
+
ui.notify(
|
88
|
+
f"任务 {tid[:8]} {'已取消' if ok else '取消失败'}",
|
89
|
+
color=("primary" if ok else "negative"),
|
90
|
+
)
|
91
|
+
|
92
|
+
ui.button("取消", on_click=cancel_this)
|
93
|
+
else:
|
94
|
+
ui.button(
|
95
|
+
"取消",
|
96
|
+
on_click=lambda: ui.notify("任务已结束,无法取消"),
|
97
|
+
).props("disable")
|
98
|
+
|
99
|
+
# meta grid
|
100
|
+
with ui.column().classes("w-full gap-1 mt-2"):
|
101
|
+
_meta_row("站点", t.site)
|
102
|
+
_meta_row("书号", t.book_id)
|
103
|
+
if t.status == "failed" and t.error:
|
104
|
+
with ui.row().classes("items-start justify-between w-full"):
|
105
|
+
ui.label("错误").classes("text-xs text-grey-7")
|
106
|
+
ui.label(t.error).classes("text-xs text-negative q-ml-md")
|
107
|
+
|
108
|
+
# progress / summary
|
109
|
+
with ui.column().classes("w-full mt-2"):
|
110
|
+
_progress_block(t)
|
111
|
+
|
112
|
+
|
113
|
+
@ui.page("/progress") # type: ignore[misc]
|
114
|
+
def page_progress() -> None:
|
115
|
+
navbar("progress")
|
116
|
+
ui.label("正在下载 / 历史记录").classes("text-lg")
|
117
|
+
setup_dialog()
|
118
|
+
|
119
|
+
@ui.refreshable # type: ignore[misc]
|
120
|
+
def section() -> None:
|
121
|
+
s = manager.snapshot()
|
122
|
+
|
123
|
+
# Active first
|
124
|
+
ui.label("运行中 / 等待中").classes("text-base mt-2")
|
125
|
+
with ui.card().classes("w-full"):
|
126
|
+
running = s["running"]
|
127
|
+
pending = s["pending"]
|
128
|
+
if not running and not pending:
|
129
|
+
ui.label("暂无").classes("text-sm text-grey-6")
|
130
|
+
else:
|
131
|
+
if running:
|
132
|
+
_task_card(running, active=True)
|
133
|
+
for t in pending:
|
134
|
+
_task_card(t, active=True)
|
135
|
+
|
136
|
+
# History next
|
137
|
+
ui.label("已完成 / 已取消 / 失败").classes("text-base mt-4")
|
138
|
+
with ui.card().classes("w-full"):
|
139
|
+
if not s["completed"]:
|
140
|
+
ui.label("暂无").classes("text-sm text-grey-6")
|
141
|
+
else:
|
142
|
+
for t in s["completed"]:
|
143
|
+
_task_card(t, active=False)
|
144
|
+
|
145
|
+
# periodic refresh
|
146
|
+
ui.timer(0.5, section.refresh)
|
147
|
+
section()
|
@@ -0,0 +1,329 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.pages.search
|
4
|
+
---------------------------------
|
5
|
+
|
6
|
+
Search UI with a settings dropdown, persistent state, and paginated results.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import contextlib
|
12
|
+
from collections.abc import Callable
|
13
|
+
from math import ceil
|
14
|
+
from typing import Any
|
15
|
+
|
16
|
+
from nicegui import ui
|
17
|
+
from nicegui.elements.number import Number
|
18
|
+
from nicegui.events import ValueChangeEventArguments
|
19
|
+
|
20
|
+
from novel_downloader.core import search
|
21
|
+
from novel_downloader.models import SearchResult
|
22
|
+
from novel_downloader.web.components import navbar
|
23
|
+
from novel_downloader.web.services import manager, setup_dialog
|
24
|
+
|
25
|
+
_SUPPORT_SITES = {
|
26
|
+
"aaatxt": "3A电子书",
|
27
|
+
"biquge": "笔趣阁",
|
28
|
+
"dxmwx": "大熊猫文学网",
|
29
|
+
"eightnovel": "无限轻小说",
|
30
|
+
"esjzone": "ESJ Zone",
|
31
|
+
"hetushu": "和图书",
|
32
|
+
"i25zw": "25中文网",
|
33
|
+
"ixdzs8": "爱下电子书",
|
34
|
+
"jpxs123": "精品小说网",
|
35
|
+
"piaotia": "飘天文学网",
|
36
|
+
"qbtr": "全本同人小说",
|
37
|
+
"qianbi": "铅笔小说",
|
38
|
+
"quanben5": "全本小说网",
|
39
|
+
"shuhaige": "书海阁小说网",
|
40
|
+
"tongrenquan": "同人圈",
|
41
|
+
"ttkan": "天天看小说",
|
42
|
+
# "wanbengo": "完本神站",
|
43
|
+
"xiaoshuowu": "小说屋",
|
44
|
+
"xiguashuwu": "西瓜书屋",
|
45
|
+
"xs63b": "小说路上",
|
46
|
+
# "xshbook": "小说虎",
|
47
|
+
}
|
48
|
+
|
49
|
+
_DEFAULT_TIMEOUT = 10.0
|
50
|
+
_DEFAULT_SITE_LIMIT = 30
|
51
|
+
_PAGE_SIZE = 20
|
52
|
+
_PAGER_WIDTH = 9
|
53
|
+
|
54
|
+
_STATE: dict[str, dict[str, Any]] = {}
|
55
|
+
|
56
|
+
|
57
|
+
def _get_state() -> dict[str, Any]:
|
58
|
+
cid = ui.context.client.id
|
59
|
+
if cid not in _STATE:
|
60
|
+
_STATE[cid] = {
|
61
|
+
"query": "",
|
62
|
+
"sites": None, # list[str] | None (None => search all)
|
63
|
+
"per_site_limit": _DEFAULT_SITE_LIMIT,
|
64
|
+
"timeout": _DEFAULT_TIMEOUT,
|
65
|
+
"results": [], # list[SearchResult]
|
66
|
+
"page": 1,
|
67
|
+
"page_size": _PAGE_SIZE,
|
68
|
+
}
|
69
|
+
return _STATE[cid]
|
70
|
+
|
71
|
+
|
72
|
+
def _cleanup_state() -> None:
|
73
|
+
cid = ui.context.client.id
|
74
|
+
_STATE.pop(cid, None)
|
75
|
+
|
76
|
+
|
77
|
+
def _coerce_timeout(inp: Number) -> float:
|
78
|
+
v = inp.value
|
79
|
+
try:
|
80
|
+
v = float(v)
|
81
|
+
if v <= 0:
|
82
|
+
raise ValueError
|
83
|
+
except (TypeError, ValueError):
|
84
|
+
ui.notify("超时需 > 0 秒,已重置为 10.0", type="warning")
|
85
|
+
v = _DEFAULT_TIMEOUT
|
86
|
+
inp.set_value(v)
|
87
|
+
inp.sanitize()
|
88
|
+
return float(v)
|
89
|
+
|
90
|
+
|
91
|
+
def _coerce_psl(inp: Number) -> int:
|
92
|
+
v = inp.value
|
93
|
+
try:
|
94
|
+
v = int(v)
|
95
|
+
if v <= 0:
|
96
|
+
raise ValueError
|
97
|
+
except (TypeError, ValueError):
|
98
|
+
ui.notify("单站条数上限需为正整数,已重置为 5", type="warning")
|
99
|
+
v = _DEFAULT_SITE_LIMIT
|
100
|
+
inp.set_value(v)
|
101
|
+
inp.sanitize()
|
102
|
+
return int(v)
|
103
|
+
|
104
|
+
|
105
|
+
def _render_placeholder_cover() -> None:
|
106
|
+
with ui.element("div").classes(
|
107
|
+
"w-[72px] h-[96px] bg-grey-3 rounded-md flex items-center " "justify-center"
|
108
|
+
):
|
109
|
+
ui.icon("book").classes("text-grey-6 text-3xl")
|
110
|
+
|
111
|
+
|
112
|
+
def _render_result_row(r: SearchResult) -> None:
|
113
|
+
with (
|
114
|
+
ui.card().classes("w-full"),
|
115
|
+
ui.row().classes("items-start justify-between w-full gap-3"),
|
116
|
+
):
|
117
|
+
cover = (r.get("cover_url") or "").strip()
|
118
|
+
if cover.startswith(("http://", "https://")):
|
119
|
+
ui.image(cover).classes("w-[72px] h-[96px] object-cover rounded-md")
|
120
|
+
else:
|
121
|
+
_render_placeholder_cover()
|
122
|
+
|
123
|
+
with ui.column().classes("gap-1 grow"):
|
124
|
+
ui.link(r["title"], r["book_url"], new_tab=True).classes(
|
125
|
+
"text-base font-medium"
|
126
|
+
)
|
127
|
+
ui.label(
|
128
|
+
f"{r['author']} · {r['word_count']} · 更新于 {r['update_date']}"
|
129
|
+
).classes("text-xs text-grey-6")
|
130
|
+
ui.label(r["latest_chapter"]).classes("text-sm text-grey-7")
|
131
|
+
ui.label(f"{r['site']} · ID: {r['book_id']}").classes("text-xs text-grey-5")
|
132
|
+
|
133
|
+
async def _add_task() -> None:
|
134
|
+
title = r["title"]
|
135
|
+
ui.notify(f"已添加任务:{title}")
|
136
|
+
await manager.add_task(title=title, site=r["site"], book_id=r["book_id"])
|
137
|
+
|
138
|
+
ui.button("下载", color="primary", on_click=_add_task).props("unelevated")
|
139
|
+
|
140
|
+
|
141
|
+
def _build_settings_dropdown(
|
142
|
+
state: dict[str, Any],
|
143
|
+
) -> tuple[Callable[[], list[str] | None], Callable[[], int], Callable[[], float]]:
|
144
|
+
"""
|
145
|
+
Create settings button + anchored menu with initial values from state.
|
146
|
+
|
147
|
+
Returns a tuple of getter functions:
|
148
|
+
* get_sites(): list of site keys, or None if none selected
|
149
|
+
* get_psl(): per-site limit (int)
|
150
|
+
* get_timeout(): timeout (float)
|
151
|
+
"""
|
152
|
+
site_cbs: dict[str, Any] = {}
|
153
|
+
|
154
|
+
settings_btn = ui.button("设置").props("outline icon=settings")
|
155
|
+
with settings_btn:
|
156
|
+
menu = ui.menu().props("no-parent-event")
|
157
|
+
with menu:
|
158
|
+
ui.label("站点选择").classes("text-sm text-grey-7 q-mb-xs")
|
159
|
+
|
160
|
+
with ui.row().classes("gap-2"):
|
161
|
+
|
162
|
+
def _select_all() -> None:
|
163
|
+
for cb in site_cbs.values():
|
164
|
+
cb.set_value(True)
|
165
|
+
|
166
|
+
def _clear_all() -> None:
|
167
|
+
for cb in site_cbs.values():
|
168
|
+
cb.set_value(False)
|
169
|
+
|
170
|
+
ui.button("全选", on_click=_select_all).props("dense")
|
171
|
+
ui.button("清空", on_click=_clear_all).props("dense")
|
172
|
+
|
173
|
+
ui.separator()
|
174
|
+
|
175
|
+
with (
|
176
|
+
ui.scroll_area().classes("w-[300px] max-h-[260px] q-mt-xs"),
|
177
|
+
ui.column().classes("gap-1"),
|
178
|
+
):
|
179
|
+
selected = set(state.get("sites") or [])
|
180
|
+
for key, label in _SUPPORT_SITES.items():
|
181
|
+
site_cbs[key] = ui.checkbox(label, value=(key in selected))
|
182
|
+
|
183
|
+
ui.separator()
|
184
|
+
ui.label("高级设置").classes("text-sm text-grey-7 q-mt-sm")
|
185
|
+
|
186
|
+
psl_in = (
|
187
|
+
ui.number(
|
188
|
+
"单站条数上限",
|
189
|
+
value=state["per_site_limit"],
|
190
|
+
min=1,
|
191
|
+
step=1,
|
192
|
+
)
|
193
|
+
.without_auto_validation()
|
194
|
+
.classes("w-[180px]")
|
195
|
+
)
|
196
|
+
timeout_in = (
|
197
|
+
ui.number(
|
198
|
+
"超时(秒)",
|
199
|
+
value=state["timeout"],
|
200
|
+
format="%.1f",
|
201
|
+
min=0.1,
|
202
|
+
step=0.1,
|
203
|
+
)
|
204
|
+
.without_auto_validation()
|
205
|
+
.classes("w-[180px]")
|
206
|
+
)
|
207
|
+
|
208
|
+
settings_btn.on("click", lambda: menu.open())
|
209
|
+
|
210
|
+
def _get_sites() -> list[str] | None:
|
211
|
+
chosen = [k for k, cb in site_cbs.items() if bool(cb.value)]
|
212
|
+
return chosen or None
|
213
|
+
|
214
|
+
def _get_psl() -> int:
|
215
|
+
val = _coerce_psl(psl_in)
|
216
|
+
state["per_site_limit"] = val
|
217
|
+
return val
|
218
|
+
|
219
|
+
def _get_timeout() -> float:
|
220
|
+
val = _coerce_timeout(timeout_in)
|
221
|
+
state["timeout"] = val
|
222
|
+
return val
|
223
|
+
|
224
|
+
return _get_sites, _get_psl, _get_timeout
|
225
|
+
|
226
|
+
|
227
|
+
@ui.page("/") # type: ignore[misc]
|
228
|
+
def page_search() -> None:
|
229
|
+
navbar("search")
|
230
|
+
ui.label("搜索页面").classes("text-lg")
|
231
|
+
setup_dialog()
|
232
|
+
|
233
|
+
state = _get_state()
|
234
|
+
|
235
|
+
# settings (left) + query (middle) + search (right)
|
236
|
+
with ui.row().classes("items-center gap-2 my-2 w-full"):
|
237
|
+
get_sites, get_psl, get_timeout = _build_settings_dropdown(state)
|
238
|
+
|
239
|
+
query_in = (
|
240
|
+
ui.input("输入关键字", value=state["query"])
|
241
|
+
.props("outlined dense clearable")
|
242
|
+
.classes("min-w-[320px] grow")
|
243
|
+
)
|
244
|
+
|
245
|
+
search_btn = ui.button("搜索", color="primary").props("unelevated")
|
246
|
+
|
247
|
+
# results & pagination container
|
248
|
+
list_area = ui.column().classes("w-full")
|
249
|
+
pager_area = ui.row().classes("items-center justify-center w-full q-mt-md")
|
250
|
+
|
251
|
+
@ui.refreshable # type: ignore[misc]
|
252
|
+
def render_results() -> None:
|
253
|
+
list_area.clear()
|
254
|
+
pager_area.clear()
|
255
|
+
|
256
|
+
results: list[SearchResult] = state["results"]
|
257
|
+
total = len(results)
|
258
|
+
page_size = int(state["page_size"])
|
259
|
+
total_pages = max(1, ceil(total / page_size))
|
260
|
+
page = max(1, min(int(state["page"]), total_pages))
|
261
|
+
state["page"] = page
|
262
|
+
|
263
|
+
start = (page - 1) * page_size
|
264
|
+
end = min(total, start + page_size)
|
265
|
+
current = results[start:end]
|
266
|
+
|
267
|
+
tip = (
|
268
|
+
f"共 {total} 条结果(第 {page}/{total_pages} 页)"
|
269
|
+
if state["sites"]
|
270
|
+
else f"共 {total} 条结果(第 {page}/{total_pages} 页,已搜索全部站点)"
|
271
|
+
)
|
272
|
+
|
273
|
+
with list_area:
|
274
|
+
ui.label(tip).classes("text-sm text-grey-7")
|
275
|
+
with ui.column().classes("w-full gap-2"):
|
276
|
+
for r in current:
|
277
|
+
_render_result_row(r)
|
278
|
+
|
279
|
+
# pagination (only show if more than 1 page)
|
280
|
+
if total_pages > 1:
|
281
|
+
|
282
|
+
def _on_page_change(e: ValueChangeEventArguments) -> None:
|
283
|
+
try:
|
284
|
+
state["page"] = int(e.value or 1)
|
285
|
+
except Exception:
|
286
|
+
state["page"] = 1
|
287
|
+
render_results.refresh()
|
288
|
+
|
289
|
+
with pager_area:
|
290
|
+
ui.pagination(
|
291
|
+
1, # min
|
292
|
+
total_pages, # max
|
293
|
+
direction_links=True,
|
294
|
+
value=page,
|
295
|
+
on_change=_on_page_change,
|
296
|
+
).props(f"max-pages={_PAGER_WIDTH} boundary-numbers ellipses")
|
297
|
+
|
298
|
+
async def do_search() -> None:
|
299
|
+
q = (query_in.value or "").strip()
|
300
|
+
if not q:
|
301
|
+
ui.notify("请输入关键词", type="warning")
|
302
|
+
return
|
303
|
+
|
304
|
+
state["query"] = q
|
305
|
+
state["sites"] = get_sites()
|
306
|
+
per_site_limit = get_psl()
|
307
|
+
timeout_val = get_timeout()
|
308
|
+
|
309
|
+
# perform search
|
310
|
+
results = await search(
|
311
|
+
keyword=q,
|
312
|
+
sites=state["sites"],
|
313
|
+
limit=None, # show all
|
314
|
+
per_site_limit=per_site_limit,
|
315
|
+
timeout=timeout_val,
|
316
|
+
)
|
317
|
+
state["results"] = results
|
318
|
+
state["page"] = 1
|
319
|
+
render_results.refresh()
|
320
|
+
|
321
|
+
search_btn.on("click", do_search)
|
322
|
+
query_in.on("keydown.enter", do_search)
|
323
|
+
|
324
|
+
# initial render
|
325
|
+
render_results()
|
326
|
+
|
327
|
+
# clean up state on disconnect to avoid leaks
|
328
|
+
with contextlib.suppress(Exception):
|
329
|
+
ui.context.client.on_disconnect(_cleanup_state)
|
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.web.services
|
4
|
+
-----------------------------
|
5
|
+
|
6
|
+
Convenience re-exports for web UI services
|
7
|
+
"""
|
8
|
+
|
9
|
+
__all__ = [
|
10
|
+
"setup_dialog",
|
11
|
+
"manager",
|
12
|
+
"DownloadTask",
|
13
|
+
"Status",
|
14
|
+
]
|
15
|
+
|
16
|
+
from .client_dialog import setup_dialog
|
17
|
+
from .task_manager import DownloadTask, Status, manager
|