novel-downloader 1.5.0__py3-none-any.whl → 2.0.0__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 +77 -64
- novel_downloader/cli/export.py +16 -20
- 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 +65 -105
- novel_downloader/config/{loader.py → file_io.py} +53 -26
- novel_downloader/core/__init__.py +1 -0
- 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 +14 -9
- 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 +17 -11
- 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} +46 -39
- 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 +4 -17
- novel_downloader/core/interfaces/parser.py +5 -6
- 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 +63 -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 +61 -66
- 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/book_info_parser.py +58 -59
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +272 -330
- novel_downloader/core/parsers/qidian/chapter_normal.py +24 -55
- novel_downloader/core/parsers/qidian/main_parser.py +11 -38
- novel_downloader/core/parsers/qidian/utils/__init__.py +1 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
- novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -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 +435 -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 +31 -82
- novel_downloader/locales/zh.json +32 -83
- 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 -22
- novel_downloader/utils/chapter_storage.py +3 -2
- novel_downloader/utils/constants.py +4 -29
- novel_downloader/utils/cookies.py +6 -18
- novel_downloader/utils/crypto_utils/__init__.py +13 -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.py → crypto_utils/rc4.py} +3 -10
- novel_downloader/utils/epub/__init__.py +1 -1
- novel_downloader/utils/epub/constants.py +57 -16
- novel_downloader/utils/epub/documents.py +88 -194
- novel_downloader/utils/epub/models.py +0 -14
- novel_downloader/utils/epub/utils.py +63 -96
- novel_downloader/utils/file_utils/__init__.py +2 -23
- novel_downloader/utils/file_utils/io.py +3 -113
- novel_downloader/utils/file_utils/sanitize.py +0 -4
- novel_downloader/utils/fontocr.py +207 -0
- novel_downloader/utils/logger.py +8 -16
- novel_downloader/utils/network.py +2 -2
- 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/time_utils/__init__.py +5 -11
- novel_downloader/utils/time_utils/datetime_utils.py +20 -29
- novel_downloader/utils/time_utils/sleep_utils.py +4 -8
- 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.0.dist-info/METADATA +171 -0
- novel_downloader-2.0.0.dist-info/RECORD +210 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.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/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/fontocr/__init__.py +0 -22
- 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.0.dist-info}/WHEEL +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
novel_downloader/cli/search.py
CHANGED
@@ -3,13 +3,18 @@
|
|
3
3
|
novel_downloader.cli.search
|
4
4
|
---------------------------
|
5
5
|
|
6
|
+
Search across supported sites, let the user pick one result, then
|
7
|
+
hand off to the download flow.
|
6
8
|
"""
|
7
9
|
|
10
|
+
from __future__ import annotations
|
11
|
+
|
8
12
|
import asyncio
|
9
13
|
from argparse import Namespace, _SubParsersAction
|
10
14
|
from collections.abc import Sequence
|
11
15
|
from pathlib import Path
|
12
16
|
|
17
|
+
from novel_downloader.cli import ui
|
13
18
|
from novel_downloader.cli.download import _download
|
14
19
|
from novel_downloader.config import ConfigAdapter, load_config
|
15
20
|
from novel_downloader.core import search
|
@@ -18,106 +23,98 @@ from novel_downloader.utils.i18n import t
|
|
18
23
|
|
19
24
|
|
20
25
|
def register_search_subcommand(subparsers: _SubParsersAction) -> None: # type: ignore
|
26
|
+
"""Register the `search` subcommand and its options."""
|
21
27
|
parser = subparsers.add_parser("search", help=t("help_search"))
|
22
28
|
|
23
29
|
parser.add_argument(
|
24
|
-
"--site",
|
25
|
-
"-s",
|
26
|
-
action="append",
|
27
|
-
metavar="SITE",
|
28
|
-
help=t("help_search_sites"),
|
29
|
-
)
|
30
|
-
parser.add_argument(
|
31
|
-
"keyword",
|
32
|
-
help=t("help_search_keyword"),
|
30
|
+
"--site", "-s", action="append", metavar="SITE", help=t("search_sites_help")
|
33
31
|
)
|
32
|
+
parser.add_argument("keyword", help=t("search_keyword_help"))
|
33
|
+
parser.add_argument("--config", type=str, help=t("help_config"))
|
34
34
|
parser.add_argument(
|
35
|
-
"--
|
36
|
-
type=str,
|
37
|
-
help=t("help_config"),
|
38
|
-
)
|
39
|
-
parser.add_argument(
|
40
|
-
"--limit",
|
41
|
-
"-l",
|
42
|
-
type=int,
|
43
|
-
default=10,
|
44
|
-
metavar="N",
|
45
|
-
help=t("help_search_limit"),
|
35
|
+
"--limit", "-l", type=int, default=20, metavar="N", help=t("search_limit_help")
|
46
36
|
)
|
47
37
|
parser.add_argument(
|
48
38
|
"--site-limit",
|
49
39
|
type=int,
|
50
40
|
default=5,
|
51
41
|
metavar="M",
|
52
|
-
help=t("
|
42
|
+
help=t("search_site_limit_help"),
|
43
|
+
)
|
44
|
+
parser.add_argument(
|
45
|
+
"--timeout",
|
46
|
+
type=float,
|
47
|
+
default=5.0,
|
48
|
+
metavar="SECS",
|
49
|
+
help=t("search_timeout_help", secs="5.0"),
|
53
50
|
)
|
54
51
|
|
55
52
|
parser.set_defaults(func=handle_search)
|
56
53
|
|
57
54
|
|
58
55
|
def handle_search(args: Namespace) -> None:
|
59
|
-
"""
|
60
|
-
Handler for the `search` subcommand. Loads config, runs the search,
|
61
|
-
prompts the user to pick one result, then kicks off download.
|
62
|
-
"""
|
56
|
+
"""Handle the `search` subcommand."""
|
63
57
|
sites: Sequence[str] | None = args.site or None
|
64
58
|
keyword: str = args.keyword
|
65
59
|
overall_limit = max(1, args.limit)
|
66
60
|
per_site_limit = max(1, args.site_limit)
|
61
|
+
timeout = max(0.1, float(args.timeout))
|
67
62
|
config_path: Path | None = Path(args.config) if args.config else None
|
68
63
|
|
69
64
|
try:
|
70
65
|
config_data = load_config(config_path)
|
71
66
|
except Exception as e:
|
72
|
-
|
67
|
+
ui.error(t("download_config_load_fail", err=str(e)))
|
73
68
|
return
|
74
69
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
70
|
+
async def _run() -> None:
|
71
|
+
results = await search(
|
72
|
+
keyword=keyword,
|
73
|
+
sites=sites,
|
74
|
+
limit=overall_limit,
|
75
|
+
per_site_limit=per_site_limit,
|
76
|
+
timeout=timeout,
|
77
|
+
)
|
81
78
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
79
|
+
chosen = _prompt_user_select(results)
|
80
|
+
if chosen is None:
|
81
|
+
return
|
82
|
+
|
83
|
+
adapter = ConfigAdapter(config=config_data, site=chosen["site"])
|
84
|
+
books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
|
85
|
+
await _download(adapter, chosen["site"], books)
|
86
86
|
|
87
|
-
|
88
|
-
books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
|
89
|
-
asyncio.run(_download(adapter, chosen["site"], books))
|
87
|
+
asyncio.run(_run())
|
90
88
|
|
91
89
|
|
92
|
-
def _prompt_user_select(
|
93
|
-
results: Sequence[SearchResult],
|
94
|
-
max_attempts: int = 3,
|
95
|
-
) -> SearchResult | None:
|
90
|
+
def _prompt_user_select(results: Sequence[SearchResult]) -> SearchResult | None:
|
96
91
|
"""
|
97
|
-
|
92
|
+
Show a Rich table of results and ask the user to pick one by index.
|
98
93
|
|
99
|
-
:param results:
|
100
|
-
:
|
101
|
-
:return: The chosen SearchResult, or None if cancelled/failed.
|
94
|
+
:param results: A sequence of SearchResult dicts.
|
95
|
+
:return: The chosen SearchResult, or None if cancelled/no results.
|
102
96
|
"""
|
103
97
|
if not results:
|
104
|
-
|
98
|
+
ui.warn(t("no_results"))
|
105
99
|
return None
|
106
100
|
|
107
|
-
#
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
101
|
+
columns = ["#", "Title", "Author", "Latest", "Updated", "Site", "Book ID"]
|
102
|
+
rows = []
|
103
|
+
for i, r in enumerate(results, 1):
|
104
|
+
rows.append(
|
105
|
+
[
|
106
|
+
str(i),
|
107
|
+
r["title"],
|
108
|
+
r["author"],
|
109
|
+
r["latest_chapter"],
|
110
|
+
r["update_date"],
|
111
|
+
r["site"],
|
112
|
+
r["book_id"],
|
113
|
+
]
|
114
|
+
)
|
115
|
+
ui.render_table("Search Results", columns, rows)
|
116
|
+
|
117
|
+
idx = ui.select_index(t("prompt_select_index"), len(results))
|
118
|
+
if idx is None:
|
119
|
+
return None
|
120
|
+
return results[idx - 1]
|
@@ -0,0 +1,156 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.cli.ui
|
4
|
+
-----------------------
|
5
|
+
|
6
|
+
A small set of Rich-based helpers to keep CLI presentation and prompts
|
7
|
+
consistent across subcommands.
|
8
|
+
|
9
|
+
Public API:
|
10
|
+
- info, success, warn, error
|
11
|
+
- confirm
|
12
|
+
- prompt, prompt_password
|
13
|
+
- render_table
|
14
|
+
- select_index
|
15
|
+
- print_progress
|
16
|
+
"""
|
17
|
+
|
18
|
+
from __future__ import annotations
|
19
|
+
|
20
|
+
from collections.abc import Iterable, Sequence
|
21
|
+
|
22
|
+
from rich.console import Console
|
23
|
+
from rich.prompt import Confirm, Prompt
|
24
|
+
from rich.table import Table
|
25
|
+
|
26
|
+
_CONSOLE = Console()
|
27
|
+
|
28
|
+
|
29
|
+
def info(message: str) -> None:
|
30
|
+
"""Print a neutral informational message."""
|
31
|
+
_CONSOLE.print(message)
|
32
|
+
|
33
|
+
|
34
|
+
def success(message: str) -> None:
|
35
|
+
"""Print a success message in a friendly color."""
|
36
|
+
_CONSOLE.print(f"[green]{message}[/]")
|
37
|
+
|
38
|
+
|
39
|
+
def warn(message: str) -> None:
|
40
|
+
"""Print a warning message."""
|
41
|
+
_CONSOLE.print(f"[yellow]{message}[/]")
|
42
|
+
|
43
|
+
|
44
|
+
def error(message: str) -> None:
|
45
|
+
"""Print an error message."""
|
46
|
+
_CONSOLE.print(f"[red]{message}[/]")
|
47
|
+
|
48
|
+
|
49
|
+
def confirm(message: str, *, default: bool = False) -> bool:
|
50
|
+
"""
|
51
|
+
Ask a yes/no question.
|
52
|
+
|
53
|
+
:param message: The question to display (without [y/N] suffix).
|
54
|
+
:param default: Default choice (pressing Enter = Yes if True, No if False).
|
55
|
+
:return: True if user confirms (Yes), otherwise False.
|
56
|
+
"""
|
57
|
+
try:
|
58
|
+
result: bool = Confirm.ask(f"[bold]{message}[/bold]", default=default)
|
59
|
+
return result
|
60
|
+
except (KeyboardInterrupt, EOFError):
|
61
|
+
warn("Cancelled.")
|
62
|
+
return False
|
63
|
+
|
64
|
+
|
65
|
+
def prompt(message: str, *, default: str | None = None) -> str:
|
66
|
+
"""
|
67
|
+
Prompt user for a line of text.
|
68
|
+
|
69
|
+
:param message: Prompt message.
|
70
|
+
:param default: Default value if the user presses Enter.
|
71
|
+
:return: The user's input.
|
72
|
+
"""
|
73
|
+
try:
|
74
|
+
result: str = Prompt.ask(message, default=default or "")
|
75
|
+
return result
|
76
|
+
except (KeyboardInterrupt, EOFError):
|
77
|
+
warn("Cancelled.")
|
78
|
+
return default or ""
|
79
|
+
|
80
|
+
|
81
|
+
def prompt_password(message: str) -> str:
|
82
|
+
"""
|
83
|
+
Prompt user for a password/secret value (no echo).
|
84
|
+
|
85
|
+
:param message: Prompt message.
|
86
|
+
:return: The user's input (may be empty).
|
87
|
+
"""
|
88
|
+
try:
|
89
|
+
result: str = Prompt.ask(message, password=True)
|
90
|
+
return result
|
91
|
+
except (KeyboardInterrupt, EOFError):
|
92
|
+
warn("Cancelled.")
|
93
|
+
return ""
|
94
|
+
|
95
|
+
|
96
|
+
def render_table(
|
97
|
+
title: str,
|
98
|
+
columns: Sequence[str],
|
99
|
+
rows: Iterable[Sequence[str]],
|
100
|
+
) -> None:
|
101
|
+
"""
|
102
|
+
Render a simple full-width table.
|
103
|
+
|
104
|
+
:param title: Table title.
|
105
|
+
:param columns: Column names.
|
106
|
+
:param rows: Row data; each row must have the same length as `columns`.
|
107
|
+
"""
|
108
|
+
table = Table(title=title, show_lines=True, expand=True)
|
109
|
+
for col in columns:
|
110
|
+
table.add_column(col, overflow="fold")
|
111
|
+
for row in rows:
|
112
|
+
table.add_row(*[str(x) for x in row])
|
113
|
+
_CONSOLE.print(table)
|
114
|
+
|
115
|
+
|
116
|
+
def select_index(prompt_text: str, total: int) -> int | None:
|
117
|
+
"""
|
118
|
+
Prompt user to select an index in [1..total]. Empty input cancels.
|
119
|
+
|
120
|
+
:param prompt_text: Displayed prompt (e.g., 'Select index').
|
121
|
+
:param total: Maximum valid index (minimum is 1).
|
122
|
+
:return: Selected 1-based index, or None if user cancels.
|
123
|
+
"""
|
124
|
+
if total <= 0:
|
125
|
+
return None
|
126
|
+
valid_choices = [str(i) for i in range(1, total + 1)]
|
127
|
+
choice = Prompt.ask(
|
128
|
+
prompt_text,
|
129
|
+
choices=valid_choices + [""],
|
130
|
+
show_choices=False,
|
131
|
+
default="",
|
132
|
+
show_default=False,
|
133
|
+
).strip()
|
134
|
+
if not choice:
|
135
|
+
return None
|
136
|
+
return int(choice)
|
137
|
+
|
138
|
+
|
139
|
+
def print_progress(
|
140
|
+
done: int,
|
141
|
+
total: int,
|
142
|
+
*,
|
143
|
+
prefix: str = "Progress",
|
144
|
+
unit: str = "item",
|
145
|
+
) -> None:
|
146
|
+
"""
|
147
|
+
Print a lightweight progress line.
|
148
|
+
|
149
|
+
:param done: Completed count.
|
150
|
+
:param total: Total count.
|
151
|
+
:param prefix: Text prefix shown before numbers.
|
152
|
+
:param unit: Logical unit name (e.g., 'item').
|
153
|
+
"""
|
154
|
+
total = max(1, total)
|
155
|
+
pct = done / total * 100.0
|
156
|
+
_CONSOLE.print(f"[dim]{prefix}[/] {done}/{total} {unit} ({pct:.2f}%)")
|
@@ -4,17 +4,20 @@ novel_downloader.config
|
|
4
4
|
-----------------------
|
5
5
|
|
6
6
|
Unified interface for loading and adapting configuration files.
|
7
|
-
|
8
|
-
This module provides:
|
9
|
-
- load_config: loads YAML config from file path with fallback support
|
10
|
-
- ConfigAdapter: maps raw config + site name to structured config models
|
11
7
|
"""
|
12
8
|
|
13
9
|
__all__ = [
|
10
|
+
"get_config_value",
|
14
11
|
"load_config",
|
12
|
+
"save_config",
|
15
13
|
"save_config_file",
|
16
14
|
"ConfigAdapter",
|
17
15
|
]
|
18
16
|
|
19
17
|
from .adapter import ConfigAdapter
|
20
|
-
from .
|
18
|
+
from .file_io import (
|
19
|
+
get_config_value,
|
20
|
+
load_config,
|
21
|
+
save_config,
|
22
|
+
save_config_file,
|
23
|
+
)
|
@@ -8,18 +8,19 @@ site name into structured dataclass-based config models.
|
|
8
8
|
"""
|
9
9
|
|
10
10
|
import json
|
11
|
-
from typing import Any, cast
|
11
|
+
from typing import Any, TypeVar, cast
|
12
12
|
|
13
13
|
from novel_downloader.models import (
|
14
14
|
BookConfig,
|
15
15
|
DownloaderConfig,
|
16
16
|
ExporterConfig,
|
17
17
|
FetcherConfig,
|
18
|
-
LogLevel,
|
19
18
|
ParserConfig,
|
20
19
|
TextCleanerConfig,
|
21
20
|
)
|
22
21
|
|
22
|
+
T = TypeVar("T")
|
23
|
+
|
23
24
|
|
24
25
|
class ConfigAdapter:
|
25
26
|
"""
|
@@ -27,129 +28,78 @@ class ConfigAdapter:
|
|
27
28
|
into structured dataclass configuration models.
|
28
29
|
"""
|
29
30
|
|
30
|
-
_ALLOWED_LOG_LEVELS: tuple[LogLevel, ...] = (
|
31
|
-
"DEBUG",
|
32
|
-
"INFO",
|
33
|
-
"WARNING",
|
34
|
-
"ERROR",
|
35
|
-
)
|
36
|
-
|
37
31
|
def __init__(self, config: dict[str, Any], site: str):
|
38
32
|
"""
|
39
33
|
Initialize the adapter.
|
40
34
|
|
41
35
|
:param config: The fully loaded configuration dictionary.
|
42
|
-
:param site:
|
36
|
+
:param site: The current site name (e.g. "qidian").
|
43
37
|
"""
|
44
38
|
self._config = config
|
45
39
|
self._site = site
|
40
|
+
self._site_cfg: dict[str, Any] = self._get_site_cfg()
|
41
|
+
self._gen_cfg: dict[str, Any] = config.get("general") or {}
|
46
42
|
|
47
43
|
def get_fetcher_config(self) -> FetcherConfig:
|
48
44
|
"""
|
49
45
|
Build a FetcherConfig from the raw configuration.
|
50
46
|
|
51
|
-
Reads from:
|
52
|
-
- config["general"] for global defaults (e.g. request_interval)
|
53
|
-
- config["requests"] for HTTP-specific settings (timeouts, retries, etc.)
|
54
|
-
- site-specific overrides under config["sites"][site]
|
55
|
-
|
56
47
|
:return: A FetcherConfig instance with all fields populated.
|
57
48
|
"""
|
58
|
-
gen = self._config.get("general", {})
|
59
|
-
req = self._config.get("requests", {})
|
60
|
-
site_cfg = self._get_site_cfg()
|
61
49
|
return FetcherConfig(
|
62
|
-
request_interval=
|
63
|
-
retry_times=
|
64
|
-
backoff_factor=
|
65
|
-
timeout=
|
66
|
-
max_connections=
|
67
|
-
max_rps=
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
user_agent=req.get("user_agent", None),
|
73
|
-
headers=req.get("headers", None),
|
74
|
-
browser_type=req.get("browser_type", "chromium"),
|
75
|
-
verify_ssl=req.get("verify_ssl", True),
|
50
|
+
request_interval=self._get_gen_cfg("request_interval", 2.0),
|
51
|
+
retry_times=self._get_gen_cfg("retry_times", 3),
|
52
|
+
backoff_factor=self._get_gen_cfg("backoff_factor", 2.0),
|
53
|
+
timeout=self._get_gen_cfg("timeout", 30.0),
|
54
|
+
max_connections=self._get_gen_cfg("max_connections", 10),
|
55
|
+
max_rps=self._get_gen_cfg("max_rps", 1000.0),
|
56
|
+
user_agent=self._get_gen_cfg("user_agent", None),
|
57
|
+
headers=self._get_gen_cfg("headers", None),
|
58
|
+
verify_ssl=self._get_gen_cfg("verify_ssl", True),
|
59
|
+
locale_style=self._get_gen_cfg("locale_style", "simplified"),
|
76
60
|
)
|
77
61
|
|
78
62
|
def get_downloader_config(self) -> DownloaderConfig:
|
79
63
|
"""
|
80
64
|
Build a DownloaderConfig using both general and site-specific settings.
|
81
65
|
|
82
|
-
Reads from:
|
83
|
-
- config["general"] for download directories, worker counts, etc.
|
84
|
-
- config["requests"] for retry and backoff settings
|
85
|
-
- config["general"]["debug"] for debug toggles (e.g. save_html)
|
86
|
-
- config["sites"][site] for login credentials and mode
|
87
|
-
|
88
66
|
:return: A DownloaderConfig instance with all fields populated.
|
89
67
|
"""
|
90
68
|
gen = self._config.get("general", {})
|
91
|
-
req = self._config.get("requests", {})
|
92
69
|
debug = gen.get("debug", {})
|
93
|
-
site_cfg = self._get_site_cfg()
|
94
70
|
return DownloaderConfig(
|
95
|
-
request_interval=
|
96
|
-
retry_times=
|
97
|
-
backoff_factor=
|
71
|
+
request_interval=self._get_gen_cfg("request_interval", 2.0),
|
72
|
+
retry_times=self._get_gen_cfg("retry_times", 3),
|
73
|
+
backoff_factor=self._get_gen_cfg("backoff_factor", 2.0),
|
74
|
+
workers=self._get_gen_cfg("workers", 2),
|
75
|
+
skip_existing=self._get_gen_cfg("skip_existing", True),
|
76
|
+
login_required=self._site_cfg.get("login_required", False),
|
77
|
+
save_html=debug.get("save_html", False),
|
98
78
|
raw_data_dir=gen.get("raw_data_dir", "./raw_data"),
|
99
79
|
cache_dir=gen.get("cache_dir", "./novel_cache"),
|
100
|
-
workers=gen.get("workers", 2),
|
101
|
-
skip_existing=gen.get("skip_existing", True),
|
102
|
-
login_required=site_cfg.get("login_required", False),
|
103
|
-
save_html=debug.get("save_html", False),
|
104
|
-
mode=site_cfg.get("mode", "session"),
|
105
80
|
storage_batch_size=gen.get("storage_batch_size", 1),
|
106
|
-
username=site_cfg.get("username", ""),
|
107
|
-
password=site_cfg.get("password", ""),
|
108
|
-
cookies=site_cfg.get("cookies", ""),
|
109
81
|
)
|
110
82
|
|
111
83
|
def get_parser_config(self) -> ParserConfig:
|
112
84
|
"""
|
113
85
|
Build a ParserConfig from general, OCR, and site-specific settings.
|
114
86
|
|
115
|
-
Reads from:
|
116
|
-
- config["general"]["cache_dir"] for where to cache intermediate parses
|
117
|
-
- config["general"]["font_ocr"] for font-decoding and OCR options
|
118
|
-
- config["sites"][site] for parsing mode and truncation behavior
|
119
|
-
|
120
87
|
:return: A ParserConfig instance with all fields populated.
|
121
88
|
"""
|
122
89
|
gen = self._config.get("general", {})
|
123
90
|
font_ocr = gen.get("font_ocr", {})
|
124
|
-
site_cfg = self._get_site_cfg()
|
125
91
|
return ParserConfig(
|
126
92
|
cache_dir=gen.get("cache_dir", "./novel_cache"),
|
127
|
-
use_truncation=
|
93
|
+
use_truncation=self._site_cfg.get("use_truncation", True),
|
128
94
|
decode_font=font_ocr.get("decode_font", False),
|
129
|
-
use_freq=font_ocr.get("use_freq", False),
|
130
|
-
use_ocr=font_ocr.get("use_ocr", True),
|
131
|
-
use_vec=font_ocr.get("use_vec", False),
|
132
|
-
ocr_version=font_ocr.get("ocr_version", "v1.0"),
|
133
95
|
save_font_debug=font_ocr.get("save_font_debug", False),
|
134
96
|
batch_size=font_ocr.get("batch_size", 32),
|
135
|
-
gpu_mem=font_ocr.get("gpu_mem", 500),
|
136
|
-
gpu_id=font_ocr.get("gpu_id", None),
|
137
|
-
ocr_weight=font_ocr.get("ocr_weight", 0.6),
|
138
|
-
vec_weight=font_ocr.get("vec_weight", 0.4),
|
139
|
-
mode=site_cfg.get("mode", "session"),
|
140
97
|
)
|
141
98
|
|
142
99
|
def get_exporter_config(self) -> ExporterConfig:
|
143
100
|
"""
|
144
101
|
Build an ExporterConfig from output and general settings.
|
145
102
|
|
146
|
-
Reads from:
|
147
|
-
- config["general"] for cache and raw data directories
|
148
|
-
- config["output"]["formats"] for which formats to generate
|
149
|
-
- config["output"]["naming"] for filename templates
|
150
|
-
- config["output"]["epub"] for EPUB-specific options
|
151
|
-
- config["sites"][site] for export split mode
|
152
|
-
|
153
103
|
:return: An ExporterConfig instance with all fields populated.
|
154
104
|
"""
|
155
105
|
gen = self._config.get("general", {})
|
@@ -158,13 +108,12 @@ class ConfigAdapter:
|
|
158
108
|
fmt = out.get("formats", {})
|
159
109
|
naming = out.get("naming", {})
|
160
110
|
epub_opts = out.get("epub", {})
|
161
|
-
site_cfg = self._get_site_cfg()
|
162
111
|
cleaner_cfg = self._dict_to_cleaner_cfg(cln)
|
163
112
|
return ExporterConfig(
|
164
113
|
cache_dir=gen.get("cache_dir", "./novel_cache"),
|
165
114
|
raw_data_dir=gen.get("raw_data_dir", "./raw_data"),
|
166
115
|
output_dir=gen.get("output_dir", "./downloads"),
|
167
|
-
clean_text=
|
116
|
+
clean_text=cln.get("clean_text", True),
|
168
117
|
make_txt=fmt.get("make_txt", True),
|
169
118
|
make_epub=fmt.get("make_epub", False),
|
170
119
|
make_md=fmt.get("make_md", False),
|
@@ -172,20 +121,34 @@ class ConfigAdapter:
|
|
172
121
|
append_timestamp=naming.get("append_timestamp", True),
|
173
122
|
filename_template=naming.get("filename_template", "{title}_{author}"),
|
174
123
|
include_cover=epub_opts.get("include_cover", True),
|
175
|
-
|
176
|
-
|
177
|
-
split_mode=site_cfg.get("split_mode", "book"),
|
124
|
+
include_picture=epub_opts.get("include_picture", True),
|
125
|
+
split_mode=self._site_cfg.get("split_mode", "book"),
|
178
126
|
cleaner_cfg=cleaner_cfg,
|
179
127
|
)
|
180
128
|
|
129
|
+
def get_login_config(self) -> dict[str, str]:
|
130
|
+
"""
|
131
|
+
Return the subset of login fields present in current site config:
|
132
|
+
* `username`
|
133
|
+
* `password`
|
134
|
+
* `cookies`
|
135
|
+
"""
|
136
|
+
out: dict[str, str] = {}
|
137
|
+
for key in ("username", "password", "cookies"):
|
138
|
+
val = self._site_cfg.get(key, "")
|
139
|
+
val = val.strip()
|
140
|
+
if val:
|
141
|
+
out[key] = val
|
142
|
+
return out
|
143
|
+
|
181
144
|
def get_book_ids(self) -> list[BookConfig]:
|
182
145
|
"""
|
183
146
|
Extract the list of target books from the site configuration.
|
184
147
|
|
185
148
|
The site config may specify book_ids as:
|
186
|
-
|
187
|
-
|
188
|
-
|
149
|
+
* a single string or integer
|
150
|
+
* a dict with book_id and optional start_id, end_id, ignore_ids
|
151
|
+
* a list of the above types
|
189
152
|
|
190
153
|
:return: A list of BookConfig dicts.
|
191
154
|
:raises ValueError: if the raw book_ids is neither a str/int, dict, nor list.
|
@@ -216,20 +179,14 @@ class ConfigAdapter:
|
|
216
179
|
|
217
180
|
return result
|
218
181
|
|
219
|
-
def get_log_level(self) ->
|
182
|
+
def get_log_level(self) -> str:
|
220
183
|
"""
|
221
184
|
Retrieve the logging level from [general.debug].
|
222
185
|
|
223
|
-
|
224
|
-
if not set or invalid.
|
225
|
-
|
226
|
-
:return: The configured LogLevel literal ("DEBUG", "INFO", "WARNING", "ERROR").
|
186
|
+
:return: The configured log level ("DEBUG", "INFO", "WARNING", "ERROR").
|
227
187
|
"""
|
228
188
|
debug_cfg = self._config.get("general", {}).get("debug", {})
|
229
|
-
|
230
|
-
if raw in self._ALLOWED_LOG_LEVELS:
|
231
|
-
return cast(LogLevel, raw)
|
232
|
-
return "INFO"
|
189
|
+
return debug_cfg.get("log_level") or "INFO"
|
233
190
|
|
234
191
|
@property
|
235
192
|
def site(self) -> str:
|
@@ -246,8 +203,12 @@ class ConfigAdapter:
|
|
246
203
|
:param value: The new site key in config["sites"] to use.
|
247
204
|
"""
|
248
205
|
self._site = value
|
206
|
+
self._site_cfg = self._get_site_cfg()
|
207
|
+
|
208
|
+
def _get_gen_cfg(self, key: str, default: T) -> T:
|
209
|
+
return self._site_cfg.get(key) or self._gen_cfg.get(key) or default
|
249
210
|
|
250
|
-
def _get_site_cfg(self
|
211
|
+
def _get_site_cfg(self) -> dict[str, Any]:
|
251
212
|
"""
|
252
213
|
Retrieve the configuration for a specific site.
|
253
214
|
|
@@ -259,13 +220,12 @@ class ConfigAdapter:
|
|
259
220
|
:param site: Optional override of the site name; defaults to self._site.
|
260
221
|
:return: The site-specific or common configuration dict.
|
261
222
|
"""
|
262
|
-
|
263
|
-
sites_cfg = self._config.get("sites", {}) or {}
|
223
|
+
sites_cfg = self._config.get("sites") or {}
|
264
224
|
|
265
|
-
if
|
266
|
-
return sites_cfg[
|
225
|
+
if self._site in sites_cfg:
|
226
|
+
return sites_cfg[self._site] or {}
|
267
227
|
|
268
|
-
return sites_cfg.get("common"
|
228
|
+
return sites_cfg.get("common") or {}
|
269
229
|
|
270
230
|
@staticmethod
|
271
231
|
def _dict_to_book_cfg(data: dict[str, Any]) -> BookConfig:
|
@@ -306,10 +266,10 @@ class ConfigAdapter:
|
|
306
266
|
title_repl = title_section.get("replace", {})
|
307
267
|
|
308
268
|
title_ext = title_section.get("external", {})
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
269
|
+
if title_ext.get("enabled", False):
|
270
|
+
title_ext_rm_p = title_ext.get("remove_patterns", "")
|
271
|
+
title_ext_rp_p = title_ext.get("replace", "")
|
272
|
+
|
313
273
|
title_remove_ext = cls._load_str_list(title_ext_rm_p)
|
314
274
|
title_remove += title_remove_ext
|
315
275
|
|
@@ -322,11 +282,11 @@ class ConfigAdapter:
|
|
322
282
|
content_repl = content_section.get("replace", {})
|
323
283
|
|
324
284
|
content_ext = content_section.get("external", {})
|
325
|
-
content_ext_en = content_ext.get("enabled", False)
|
326
|
-
content_ext_rm_p = content_ext.get("remove_patterns", "")
|
327
|
-
content_ext_rp_p = content_ext.get("replace", "")
|
328
285
|
|
329
|
-
if
|
286
|
+
if content_ext.get("enabled", False):
|
287
|
+
content_ext_rm_p = content_ext.get("remove_patterns", "")
|
288
|
+
content_ext_rp_p = content_ext.get("replace", "")
|
289
|
+
|
330
290
|
content_remove_ext = cls._load_str_list(content_ext_rm_p)
|
331
291
|
content_remove += content_remove_ext
|
332
292
|
|