novel-downloader 1.3.3__py3-none-any.whl → 1.4.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/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -39
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +193 -0
- novel_downloader/core/fetchers/linovelib/session.py +193 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +20 -14
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +2 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/METADATA +69 -35
- novel_downloader-1.4.1.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.tui.app
|
4
|
+
------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from textual.app import App, ComposeResult
|
11
|
+
from textual.containers import Container
|
12
|
+
from textual.widgets import Footer, Header
|
13
|
+
|
14
|
+
from novel_downloader.config import load_config
|
15
|
+
from novel_downloader.tui.screens import HomeScreen
|
16
|
+
|
17
|
+
|
18
|
+
class NovelDownloaderTUI(App): # type: ignore[misc]
|
19
|
+
TITLE = "Novel Downloader TUI"
|
20
|
+
SCREENS = {
|
21
|
+
"home": HomeScreen,
|
22
|
+
}
|
23
|
+
config: dict[str, Any]
|
24
|
+
|
25
|
+
def compose(self) -> ComposeResult:
|
26
|
+
yield Header()
|
27
|
+
yield Container(id="main_area")
|
28
|
+
yield Footer()
|
29
|
+
|
30
|
+
def on_mount(self) -> None:
|
31
|
+
self.config = load_config()
|
32
|
+
self.push_screen("home")
|
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.tui.main
|
4
|
+
-------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.tui.app import NovelDownloaderTUI
|
9
|
+
|
10
|
+
|
11
|
+
def tui_main() -> None:
|
12
|
+
app = NovelDownloaderTUI()
|
13
|
+
app.run()
|
14
|
+
|
15
|
+
|
16
|
+
if __name__ == "__main__":
|
17
|
+
tui_main()
|
@@ -0,0 +1,191 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.tui.screens.home
|
4
|
+
---------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
import logging
|
10
|
+
from typing import Any
|
11
|
+
|
12
|
+
from textual.app import ComposeResult
|
13
|
+
from textual.containers import Horizontal, Vertical
|
14
|
+
from textual.screen import Screen
|
15
|
+
from textual.widgets import Button, Input, ProgressBar, RichLog, Select, Static
|
16
|
+
|
17
|
+
from novel_downloader.config import ConfigAdapter
|
18
|
+
from novel_downloader.core.factory import (
|
19
|
+
get_downloader,
|
20
|
+
get_exporter,
|
21
|
+
get_fetcher,
|
22
|
+
get_parser,
|
23
|
+
)
|
24
|
+
from novel_downloader.core.interfaces import FetcherProtocol
|
25
|
+
from novel_downloader.models import LoginField
|
26
|
+
from novel_downloader.tui.widgets.richlog_handler import RichLogHandler
|
27
|
+
from novel_downloader.utils.i18n import t
|
28
|
+
|
29
|
+
|
30
|
+
class HomeScreen(Screen): # type: ignore[misc]
|
31
|
+
CSS_PATH = "../styles/home_layout.tcss"
|
32
|
+
|
33
|
+
def compose(self) -> ComposeResult:
|
34
|
+
yield Vertical(
|
35
|
+
self._make_title_bar(),
|
36
|
+
self._make_input_row(),
|
37
|
+
ProgressBar(id="prog", name="下载进度"),
|
38
|
+
Static("下载进度: 0/0 章", id="label-progress"),
|
39
|
+
RichLog(id="log", highlight=True, markup=False),
|
40
|
+
id="main-layout",
|
41
|
+
)
|
42
|
+
|
43
|
+
def on_mount(self) -> None:
|
44
|
+
log_widget = self.query_one("#log", RichLog)
|
45
|
+
|
46
|
+
self._log_handler = RichLogHandler(log_widget)
|
47
|
+
self._log_handler.setLevel(logging.INFO)
|
48
|
+
self._log_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
49
|
+
|
50
|
+
self._setup_logging(self._log_handler)
|
51
|
+
|
52
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
53
|
+
if event.button.id == "exit":
|
54
|
+
logging.info("退出应用")
|
55
|
+
self.app.exit()
|
56
|
+
|
57
|
+
elif event.button.id == "settings":
|
58
|
+
logging.info("设置功能暂未实现")
|
59
|
+
|
60
|
+
elif event.button.id == "download":
|
61
|
+
site = self.query_one("#site", Select).value
|
62
|
+
ids = self.query_one("#book_ids", Input).value
|
63
|
+
if not site or not ids.strip():
|
64
|
+
logging.warning("请填写完整信息")
|
65
|
+
return
|
66
|
+
id_list = {x.strip() for x in ids.split(",") if x.strip()}
|
67
|
+
adapter = ConfigAdapter(config=self.app.config, site=str(site))
|
68
|
+
asyncio.create_task(self._download(adapter, str(site), id_list))
|
69
|
+
|
70
|
+
def _make_title_bar(self) -> Horizontal:
|
71
|
+
return Horizontal(
|
72
|
+
Static("小说下载器", id="title"),
|
73
|
+
Button("设置", id="settings"),
|
74
|
+
Button("关闭", id="exit"),
|
75
|
+
id="title-bar",
|
76
|
+
)
|
77
|
+
|
78
|
+
def _make_input_row(self) -> Horizontal:
|
79
|
+
return Horizontal(
|
80
|
+
Vertical(self._make_site_select(), classes="left"),
|
81
|
+
Vertical(
|
82
|
+
Input(placeholder="输入书籍ID (支持逗号分隔)", id="book_ids"),
|
83
|
+
classes="middle",
|
84
|
+
),
|
85
|
+
Vertical(Button("下载", id="download"), classes="right"),
|
86
|
+
id="input-row",
|
87
|
+
)
|
88
|
+
|
89
|
+
def _make_site_select(self) -> Select:
|
90
|
+
return Select(
|
91
|
+
options=[
|
92
|
+
("起点中文网", "qidian"),
|
93
|
+
("笔趣阁", "biquge"),
|
94
|
+
("铅笔小说", "qianbi"),
|
95
|
+
("SF轻小说", "sfacg"),
|
96
|
+
("ESJ Zone", "esjzone"),
|
97
|
+
("百合会", "yamibo"),
|
98
|
+
("哔哩轻小说", "linovelib"),
|
99
|
+
],
|
100
|
+
prompt="选择站点",
|
101
|
+
value="qidian",
|
102
|
+
id="site",
|
103
|
+
)
|
104
|
+
|
105
|
+
async def _download(
|
106
|
+
self,
|
107
|
+
adapter: ConfigAdapter,
|
108
|
+
site: str,
|
109
|
+
valid_book_ids: set[str],
|
110
|
+
) -> None:
|
111
|
+
btn = self.query_one("#download", Button)
|
112
|
+
btn.disabled = True
|
113
|
+
try:
|
114
|
+
logging.info(f"下载请求: {site} | {valid_book_ids}")
|
115
|
+
downloader_cfg = adapter.get_downloader_config()
|
116
|
+
fetcher_cfg = adapter.get_fetcher_config()
|
117
|
+
parser_cfg = adapter.get_parser_config()
|
118
|
+
exporter_cfg = adapter.get_exporter_config()
|
119
|
+
|
120
|
+
parser = get_parser(site, parser_cfg)
|
121
|
+
exporter = get_exporter(site, exporter_cfg)
|
122
|
+
self._setup_logging(self._log_handler)
|
123
|
+
|
124
|
+
async with get_fetcher(site, fetcher_cfg) as fetcher:
|
125
|
+
if downloader_cfg.login_required and not await fetcher.load_state():
|
126
|
+
login_data = await self._prompt_login_fields(
|
127
|
+
fetcher, fetcher.login_fields, downloader_cfg
|
128
|
+
)
|
129
|
+
if not await fetcher.login(**login_data):
|
130
|
+
logging.info(t("download_login_failed"))
|
131
|
+
return
|
132
|
+
await fetcher.save_state()
|
133
|
+
|
134
|
+
downloader = get_downloader(
|
135
|
+
fetcher=fetcher,
|
136
|
+
parser=parser,
|
137
|
+
exporter=exporter,
|
138
|
+
site=site,
|
139
|
+
config=downloader_cfg,
|
140
|
+
)
|
141
|
+
|
142
|
+
for book_id in valid_book_ids:
|
143
|
+
logging.info(t("download_downloading", book_id=book_id, site=site))
|
144
|
+
await downloader.download(
|
145
|
+
book_id, progress_hook=self._update_progress
|
146
|
+
)
|
147
|
+
|
148
|
+
if downloader_cfg.login_required and fetcher.is_logged_in:
|
149
|
+
await fetcher.save_state()
|
150
|
+
finally:
|
151
|
+
btn.disabled = False
|
152
|
+
|
153
|
+
async def _prompt_login_fields(
|
154
|
+
self,
|
155
|
+
fetcher: FetcherProtocol,
|
156
|
+
fields: list[LoginField],
|
157
|
+
cfg: Any = None,
|
158
|
+
) -> dict[str, Any]:
|
159
|
+
"""
|
160
|
+
Push a LoginScreen to collect all required fields,
|
161
|
+
then return the dict of values when the user submits.
|
162
|
+
"""
|
163
|
+
# cfg_dict = asdict(cfg) if cfg else {}
|
164
|
+
# login_screen = LoginScreen(fields, cfg_dict)
|
165
|
+
# await self.app.push_screen(login_screen)
|
166
|
+
# await self.app.pop_screen()
|
167
|
+
return {}
|
168
|
+
|
169
|
+
def _setup_logging(self, handler: logging.Handler) -> None:
|
170
|
+
"""
|
171
|
+
Attach the given handler to the root logger.
|
172
|
+
"""
|
173
|
+
ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
|
174
|
+
ft_logger.setLevel(logging.ERROR)
|
175
|
+
ft_logger.propagate = False
|
176
|
+
|
177
|
+
logger = logging.getLogger()
|
178
|
+
logger.setLevel(logging.INFO)
|
179
|
+
|
180
|
+
logger.handlers = [
|
181
|
+
h for h in logger.handlers if not isinstance(h, RichLogHandler)
|
182
|
+
]
|
183
|
+
logger.addHandler(handler)
|
184
|
+
|
185
|
+
async def _update_progress(self, done: int, total: int) -> None:
|
186
|
+
prog = self.query_one("#prog", ProgressBar)
|
187
|
+
label = self.query_one("#label-progress", Static)
|
188
|
+
|
189
|
+
prog.update(total=total, progress=min(done, total))
|
190
|
+
|
191
|
+
label.update(f"下载进度: {done}/{total} 章")
|
@@ -0,0 +1,74 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.tui.screens.login
|
4
|
+
---------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from textual.app import ComposeResult
|
11
|
+
from textual.containers import Vertical
|
12
|
+
from textual.screen import Screen
|
13
|
+
from textual.widgets import Button, Input, Static
|
14
|
+
|
15
|
+
from novel_downloader.models import LoginField
|
16
|
+
|
17
|
+
|
18
|
+
class LoginScreen(Screen): # type: ignore[misc]
|
19
|
+
"""
|
20
|
+
A modal screen that gathers login fields, then fires LoginScreen.Submitted.
|
21
|
+
"""
|
22
|
+
|
23
|
+
BINDINGS = [("escape", "app.pop_screen", "取消")]
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
fields: list[LoginField],
|
28
|
+
cfg: dict[str, Any] | None = None,
|
29
|
+
) -> None:
|
30
|
+
super().__init__()
|
31
|
+
self.fields = fields
|
32
|
+
self.cfg = cfg or {}
|
33
|
+
|
34
|
+
def compose(self) -> ComposeResult:
|
35
|
+
widgets = []
|
36
|
+
for field in self.fields:
|
37
|
+
# show label and optional description
|
38
|
+
widgets.append(Static(field.label))
|
39
|
+
if field.description:
|
40
|
+
widgets.append(Static(f"[i]{field.description}[/]"))
|
41
|
+
|
42
|
+
# pick input type
|
43
|
+
if field.type == "password":
|
44
|
+
inp = Input(
|
45
|
+
placeholder=field.placeholder or "",
|
46
|
+
password=True,
|
47
|
+
id=field.name,
|
48
|
+
)
|
49
|
+
else:
|
50
|
+
inp = Input(
|
51
|
+
placeholder=field.placeholder or "",
|
52
|
+
id=field.name,
|
53
|
+
)
|
54
|
+
|
55
|
+
# pre-fill from config if present
|
56
|
+
existing = self.cfg.get(field.name, "").strip()
|
57
|
+
if existing:
|
58
|
+
inp.value = existing
|
59
|
+
|
60
|
+
widgets.append(inp)
|
61
|
+
|
62
|
+
# submit button at the end
|
63
|
+
widgets.append(Button("提交", id="submit"))
|
64
|
+
yield Vertical(*widgets, id="login-form")
|
65
|
+
|
66
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
67
|
+
if event.button.id == "submit":
|
68
|
+
data: dict[str, Any] = {}
|
69
|
+
for field in self.fields:
|
70
|
+
inp = self.query_one(f"#{field.name}", Input)
|
71
|
+
value = inp.value
|
72
|
+
if not value and self.cfg.get(field.name):
|
73
|
+
value = self.cfg[field.name]
|
74
|
+
data[field.name] = value
|
@@ -0,0 +1,79 @@
|
|
1
|
+
#main-layout {
|
2
|
+
grid-rows: 3 auto 1 auto 1fr;
|
3
|
+
grid-columns: 1fr;
|
4
|
+
grid-gutter: 1;
|
5
|
+
padding: 1;
|
6
|
+
height: 100%;
|
7
|
+
}
|
8
|
+
|
9
|
+
#title-bar {
|
10
|
+
height: 3;
|
11
|
+
layout: horizontal;
|
12
|
+
align: left middle;
|
13
|
+
padding: 0 1;
|
14
|
+
background: $boost;
|
15
|
+
}
|
16
|
+
|
17
|
+
#title {
|
18
|
+
width: 1fr;
|
19
|
+
content-align: left middle;
|
20
|
+
}
|
21
|
+
|
22
|
+
#settings,
|
23
|
+
#exit {
|
24
|
+
width: 8;
|
25
|
+
padding: 0 1;
|
26
|
+
}
|
27
|
+
|
28
|
+
#input-row {
|
29
|
+
layout: horizontal;
|
30
|
+
padding: 1 0;
|
31
|
+
overflow-x: auto;
|
32
|
+
}
|
33
|
+
|
34
|
+
#site {
|
35
|
+
width: 20;
|
36
|
+
margin-right: 1;
|
37
|
+
}
|
38
|
+
|
39
|
+
#book_ids {
|
40
|
+
width: 1fr;
|
41
|
+
min-width: 0;
|
42
|
+
margin-right: 1;
|
43
|
+
}
|
44
|
+
|
45
|
+
#download {
|
46
|
+
width: 15;
|
47
|
+
}
|
48
|
+
|
49
|
+
#site,
|
50
|
+
#book_ids,
|
51
|
+
#download {
|
52
|
+
width: 100%;
|
53
|
+
}
|
54
|
+
|
55
|
+
Button#download {
|
56
|
+
border: round $accent;
|
57
|
+
padding: 0 1;
|
58
|
+
}
|
59
|
+
Button#download:hover {
|
60
|
+
background: $accent-lighten-3;
|
61
|
+
color: $text;
|
62
|
+
}
|
63
|
+
|
64
|
+
|
65
|
+
#prog {
|
66
|
+
height: 1;
|
67
|
+
color: $success;
|
68
|
+
}
|
69
|
+
|
70
|
+
#label {
|
71
|
+
content-align: left middle;
|
72
|
+
padding-left: 1;
|
73
|
+
}
|
74
|
+
|
75
|
+
#log {
|
76
|
+
border: round $primary;
|
77
|
+
padding: 1;
|
78
|
+
overflow-y: auto;
|
79
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.tui.widgets.richlog_handler
|
4
|
+
--------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
import logging
|
9
|
+
from logging import LogRecord
|
10
|
+
|
11
|
+
from textual.widgets import RichLog
|
12
|
+
|
13
|
+
|
14
|
+
class RichLogHandler(logging.Handler):
|
15
|
+
def __init__(self, rich_log_widget: RichLog):
|
16
|
+
super().__init__()
|
17
|
+
self.rich_log_widget = rich_log_widget
|
18
|
+
|
19
|
+
def emit(self, record: LogRecord) -> None:
|
20
|
+
msg = self.format(record)
|
21
|
+
try:
|
22
|
+
self.rich_log_widget.write(msg)
|
23
|
+
except Exception:
|
24
|
+
self.handleError(record)
|
@@ -12,7 +12,13 @@ import json
|
|
12
12
|
import sqlite3
|
13
13
|
import types
|
14
14
|
from pathlib import Path
|
15
|
-
from typing import Any,
|
15
|
+
from typing import Any, Self, cast
|
16
|
+
|
17
|
+
from novel_downloader.models import (
|
18
|
+
ChapterDict,
|
19
|
+
SaveMode,
|
20
|
+
StorageBackend,
|
21
|
+
)
|
16
22
|
|
17
23
|
from .file_utils import save_as_json
|
18
24
|
|
@@ -26,27 +32,6 @@ CREATE TABLE IF NOT EXISTS "{table}" (
|
|
26
32
|
"""
|
27
33
|
|
28
34
|
|
29
|
-
class ChapterDict(TypedDict, total=True):
|
30
|
-
"""
|
31
|
-
TypedDict for a novel chapter.
|
32
|
-
|
33
|
-
Fields:
|
34
|
-
id -- Unique chapter identifier
|
35
|
-
title -- Chapter title
|
36
|
-
content -- Chapter text
|
37
|
-
extra -- Arbitrary metadata (e.g. author remarks, timestamps)
|
38
|
-
"""
|
39
|
-
|
40
|
-
id: str
|
41
|
-
title: str
|
42
|
-
content: str
|
43
|
-
extra: dict[str, Any]
|
44
|
-
|
45
|
-
|
46
|
-
BackendType = Literal["json", "sqlite"]
|
47
|
-
SaveMode = Literal["overwrite", "skip"]
|
48
|
-
|
49
|
-
|
50
35
|
class ChapterStorage:
|
51
36
|
"""
|
52
37
|
Manage storage of chapters in JSON files or an SQLite database.
|
@@ -60,7 +45,7 @@ class ChapterStorage:
|
|
60
45
|
self,
|
61
46
|
raw_base: str | Path,
|
62
47
|
namespace: str,
|
63
|
-
backend_type:
|
48
|
+
backend_type: StorageBackend = "json",
|
64
49
|
*,
|
65
50
|
batch_size: int = 1,
|
66
51
|
) -> None:
|
@@ -70,6 +55,7 @@ class ChapterStorage:
|
|
70
55
|
self._batch_size = batch_size
|
71
56
|
self._pending = 0
|
72
57
|
self._conn: sqlite3.Connection | None = None
|
58
|
+
self._existing_ids: set[str] = set()
|
73
59
|
|
74
60
|
if self.backend == "json":
|
75
61
|
self._init_json()
|
@@ -80,6 +66,7 @@ class ChapterStorage:
|
|
80
66
|
"""Prepare directory for JSON files."""
|
81
67
|
self._json_dir = self.raw_base / self.namespace
|
82
68
|
self._json_dir.mkdir(parents=True, exist_ok=True)
|
69
|
+
self._existing_ids = {p.stem for p in self._json_dir.glob("*.json")}
|
83
70
|
|
84
71
|
def _init_sql(self) -> None:
|
85
72
|
"""Prepare SQLite connection and ensure table exists."""
|
@@ -89,21 +76,13 @@ class ChapterStorage:
|
|
89
76
|
self._conn.execute(stmt)
|
90
77
|
self._conn.commit()
|
91
78
|
|
79
|
+
cur = self._conn.execute(f'SELECT id FROM "{self.namespace}"')
|
80
|
+
self._existing_ids = {row[0] for row in cur.fetchall()}
|
81
|
+
|
92
82
|
def _json_path(self, chap_id: str) -> Path:
|
93
83
|
"""Return Path for JSON file of given chapter ID."""
|
94
84
|
return self._json_dir / f"{chap_id}.json"
|
95
85
|
|
96
|
-
def _exists_json(self, chap_id: str) -> bool:
|
97
|
-
return self._json_path(chap_id).is_file()
|
98
|
-
|
99
|
-
def _exists_sql(self, chap_id: str) -> bool:
|
100
|
-
if self._conn is None:
|
101
|
-
raise RuntimeError("ChapterStorage is closed")
|
102
|
-
cur = self._conn.execute(
|
103
|
-
f'SELECT 1 FROM "{self.namespace}" WHERE id = ? LIMIT 1', (chap_id,)
|
104
|
-
)
|
105
|
-
return cur.fetchone() is not None
|
106
|
-
|
107
86
|
def exists(self, chap_id: str) -> bool:
|
108
87
|
"""
|
109
88
|
Check if a chapter exists.
|
@@ -111,10 +90,7 @@ class ChapterStorage:
|
|
111
90
|
:param chap_id: Chapter identifier.
|
112
91
|
:return: True if found, else False.
|
113
92
|
"""
|
114
|
-
|
115
|
-
return self._exists_json(chap_id)
|
116
|
-
else:
|
117
|
-
return self._exists_sql(chap_id)
|
93
|
+
return chap_id in self._existing_ids
|
118
94
|
|
119
95
|
def _load_json(self, chap_id: str) -> ChapterDict:
|
120
96
|
raw = self._json_path(chap_id).read_text(encoding="utf-8")
|
@@ -153,6 +129,7 @@ class ChapterStorage:
|
|
153
129
|
def _save_json(self, data: ChapterDict, on_exist: SaveMode) -> None:
|
154
130
|
path = self._json_path(data["id"])
|
155
131
|
save_as_json(data, path, on_exist=on_exist)
|
132
|
+
self._existing_ids.add(data["id"])
|
156
133
|
|
157
134
|
def _save_sql(self, data: ChapterDict, on_exist: SaveMode) -> None:
|
158
135
|
if self._conn is None:
|
@@ -173,6 +150,7 @@ class ChapterStorage:
|
|
173
150
|
json.dumps(data["extra"], ensure_ascii=False),
|
174
151
|
),
|
175
152
|
)
|
153
|
+
self._existing_ids.add(data["id"])
|
176
154
|
if self._batch_size == 1:
|
177
155
|
self._conn.commit()
|
178
156
|
else:
|
@@ -218,6 +196,8 @@ class ChapterStorage:
|
|
218
196
|
with self._conn:
|
219
197
|
self._conn.executemany(sql, params)
|
220
198
|
|
199
|
+
self._existing_ids.update(data["id"] for data in datas)
|
200
|
+
|
221
201
|
def save(
|
222
202
|
self,
|
223
203
|
data: ChapterDict,
|
@@ -338,3 +318,10 @@ class ChapterStorage:
|
|
338
318
|
|
339
319
|
def __del__(self) -> None:
|
340
320
|
self.close()
|
321
|
+
|
322
|
+
def __repr__(self) -> str:
|
323
|
+
return (
|
324
|
+
f"<ChapterStorage ns='{self.namespace}' "
|
325
|
+
f"backend='{self.backend}' "
|
326
|
+
f"path='{self.raw_base}'>"
|
327
|
+
)
|
@@ -9,7 +9,7 @@ Constants and default paths used throughout the NovelDownloader project.
|
|
9
9
|
from importlib.resources import files
|
10
10
|
from pathlib import Path
|
11
11
|
|
12
|
-
from platformdirs import
|
12
|
+
from platformdirs import user_config_path
|
13
13
|
|
14
14
|
# -----------------------------------------------------------------------------
|
15
15
|
# Application identity
|
@@ -20,15 +20,20 @@ APP_DIR_NAME = "novel_downloader" # Directory name for platformdirs
|
|
20
20
|
LOGGER_NAME = PACKAGE_NAME # Root logger name
|
21
21
|
|
22
22
|
SUPPORTED_SITES = {
|
23
|
-
"qidian",
|
24
23
|
"biquge",
|
24
|
+
"esjzone",
|
25
|
+
"linovelib",
|
26
|
+
"qianbi",
|
27
|
+
"qidian",
|
28
|
+
"sfacg",
|
29
|
+
"yamibo",
|
25
30
|
}
|
26
31
|
|
27
32
|
# -----------------------------------------------------------------------------
|
28
33
|
# Base directories
|
29
34
|
# -----------------------------------------------------------------------------
|
30
35
|
# Base config directory (e.g. ~/AppData/Local/novel_downloader/)
|
31
|
-
BASE_CONFIG_DIR = Path(
|
36
|
+
BASE_CONFIG_DIR = Path(user_config_path(APP_DIR_NAME, appauthor=False))
|
32
37
|
WORK_DIR = Path.cwd()
|
33
38
|
PACKAGE_ROOT: Path = Path(__file__).parent.parent
|
34
39
|
LOCALES_DIR: Path = PACKAGE_ROOT / "locales"
|
@@ -79,11 +84,9 @@ DEFAULT_USER_HEADERS = {
|
|
79
84
|
# Embedded resources (via importlib.resources)
|
80
85
|
# -----------------------------------------------------------------------------
|
81
86
|
BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.toml")
|
82
|
-
BASE_RULE_PATH = files("novel_downloader.resources.config").joinpath("rules.toml")
|
83
87
|
|
84
88
|
DEFAULT_SETTINGS_PATHS = [
|
85
89
|
BASE_CONFIG_PATH,
|
86
|
-
BASE_RULE_PATH,
|
87
90
|
]
|
88
91
|
|
89
92
|
# CSS Styles
|
@@ -101,6 +104,9 @@ VOLUME_BORDER_IMAGE_PATH = files("novel_downloader.resources.images").joinpath(
|
|
101
104
|
REPLACE_WORD_MAP_PATH = files("novel_downloader.resources.json").joinpath(
|
102
105
|
"replace_word_map.json"
|
103
106
|
)
|
107
|
+
LINOVELIB_FONT_MAP_PATH = files("novel_downloader.resources.json").joinpath(
|
108
|
+
"linovelib_font_map.json"
|
109
|
+
)
|
104
110
|
|
105
111
|
# JavaScript
|
106
112
|
QD_DECRYPT_SCRIPT_PATH = files("novel_downloader.resources.js_scripts").joinpath(
|