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
@@ -1,198 +0,0 @@
|
|
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
|
-
self.run_worker(
|
70
|
-
self._download(adapter, str(site), id_list),
|
71
|
-
name="download",
|
72
|
-
group="downloads",
|
73
|
-
description="正在下载书籍...",
|
74
|
-
)
|
75
|
-
|
76
|
-
def _make_title_bar(self) -> Horizontal:
|
77
|
-
return Horizontal(
|
78
|
-
Static("小说下载器", id="title"),
|
79
|
-
Button("设置", id="settings"),
|
80
|
-
Button("关闭", id="exit"),
|
81
|
-
id="title-bar",
|
82
|
-
)
|
83
|
-
|
84
|
-
def _make_input_row(self) -> Horizontal:
|
85
|
-
return Horizontal(
|
86
|
-
Vertical(self._make_site_select(), classes="left"),
|
87
|
-
Vertical(
|
88
|
-
Input(placeholder="输入书籍ID (支持逗号分隔)", id="book_ids"),
|
89
|
-
classes="middle",
|
90
|
-
),
|
91
|
-
Vertical(Button("下载", id="download"), classes="right"),
|
92
|
-
id="input-row",
|
93
|
-
)
|
94
|
-
|
95
|
-
def _make_site_select(self) -> Select:
|
96
|
-
return Select(
|
97
|
-
options=[
|
98
|
-
("起点中文网", "qidian"),
|
99
|
-
("笔趣阁", "biquge"),
|
100
|
-
("铅笔小说", "qianbi"),
|
101
|
-
("SF轻小说", "sfacg"),
|
102
|
-
("ESJ Zone", "esjzone"),
|
103
|
-
("百合会", "yamibo"),
|
104
|
-
("哔哩轻小说", "linovelib"),
|
105
|
-
],
|
106
|
-
prompt="选择站点",
|
107
|
-
value="qidian",
|
108
|
-
id="site",
|
109
|
-
)
|
110
|
-
|
111
|
-
async def _download(
|
112
|
-
self,
|
113
|
-
adapter: ConfigAdapter,
|
114
|
-
site: str,
|
115
|
-
book_ids: set[str],
|
116
|
-
) -> None:
|
117
|
-
btn = self.query_one("#download", Button)
|
118
|
-
btn.disabled = True
|
119
|
-
try:
|
120
|
-
logging.info(f"下载请求: {site} | {book_ids}")
|
121
|
-
downloader_cfg = adapter.get_downloader_config()
|
122
|
-
fetcher_cfg = adapter.get_fetcher_config()
|
123
|
-
parser_cfg = adapter.get_parser_config()
|
124
|
-
exporter_cfg = adapter.get_exporter_config()
|
125
|
-
|
126
|
-
parser = get_parser(site, parser_cfg)
|
127
|
-
exporter = get_exporter(site, exporter_cfg)
|
128
|
-
self._setup_logging(self._log_handler)
|
129
|
-
|
130
|
-
async with get_fetcher(site, fetcher_cfg) as fetcher:
|
131
|
-
if downloader_cfg.login_required and not await fetcher.load_state():
|
132
|
-
login_data = await self._prompt_login_fields(
|
133
|
-
fetcher, fetcher.login_fields, downloader_cfg
|
134
|
-
)
|
135
|
-
if not await fetcher.login(**login_data):
|
136
|
-
logging.info(t("download_login_failed"))
|
137
|
-
return
|
138
|
-
await fetcher.save_state()
|
139
|
-
|
140
|
-
downloader = get_downloader(
|
141
|
-
fetcher=fetcher,
|
142
|
-
parser=parser,
|
143
|
-
site=site,
|
144
|
-
config=downloader_cfg,
|
145
|
-
)
|
146
|
-
|
147
|
-
for book_id in book_ids:
|
148
|
-
logging.info(t("download_downloading", book_id=book_id, site=site))
|
149
|
-
await downloader.download(
|
150
|
-
{"book_id": book_id},
|
151
|
-
progress_hook=self._update_progress,
|
152
|
-
)
|
153
|
-
await asyncio.to_thread(exporter.export, book_id)
|
154
|
-
|
155
|
-
if downloader_cfg.login_required and fetcher.is_logged_in:
|
156
|
-
await fetcher.save_state()
|
157
|
-
finally:
|
158
|
-
btn.disabled = False
|
159
|
-
|
160
|
-
async def _prompt_login_fields(
|
161
|
-
self,
|
162
|
-
fetcher: FetcherProtocol,
|
163
|
-
fields: list[LoginField],
|
164
|
-
cfg: Any = None,
|
165
|
-
) -> dict[str, Any]:
|
166
|
-
"""
|
167
|
-
Push a LoginScreen to collect all required fields,
|
168
|
-
then return the dict of values when the user submits.
|
169
|
-
"""
|
170
|
-
# cfg_dict = asdict(cfg) if cfg else {}
|
171
|
-
# login_screen = LoginScreen(fields, cfg_dict)
|
172
|
-
# await self.app.push_screen(login_screen)
|
173
|
-
# await self.app.pop_screen()
|
174
|
-
return {}
|
175
|
-
|
176
|
-
def _setup_logging(self, handler: logging.Handler) -> None:
|
177
|
-
"""
|
178
|
-
Attach the given handler to the root logger.
|
179
|
-
"""
|
180
|
-
ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
|
181
|
-
ft_logger.setLevel(logging.ERROR)
|
182
|
-
ft_logger.propagate = False
|
183
|
-
|
184
|
-
logger = logging.getLogger()
|
185
|
-
logger.setLevel(logging.INFO)
|
186
|
-
|
187
|
-
logger.handlers = [
|
188
|
-
h for h in logger.handlers if not isinstance(h, RichLogHandler)
|
189
|
-
]
|
190
|
-
logger.addHandler(handler)
|
191
|
-
|
192
|
-
async def _update_progress(self, done: int, total: int) -> None:
|
193
|
-
prog = self.query_one("#prog", ProgressBar)
|
194
|
-
label = self.query_one("#label-progress", Static)
|
195
|
-
|
196
|
-
prog.update(total=total, progress=min(done, total))
|
197
|
-
|
198
|
-
label.update(f"下载进度: {done}/{total} 章")
|
@@ -1,74 +0,0 @@
|
|
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
|
@@ -1,79 +0,0 @@
|
|
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
|
-
}
|
@@ -1,24 +0,0 @@
|
|
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)
|
novel_downloader/utils/cache.py
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
"""
|
3
|
-
novel_downloader.utils.cache
|
4
|
-
----------------------------
|
5
|
-
|
6
|
-
Provides decorators for caching function results,
|
7
|
-
specifically optimized for configuration loading functions.
|
8
|
-
"""
|
9
|
-
|
10
|
-
from collections.abc import Callable
|
11
|
-
from functools import lru_cache, wraps
|
12
|
-
from typing import Any, TypeVar, cast
|
13
|
-
|
14
|
-
T = TypeVar("T", bound=Callable[..., Any])
|
15
|
-
|
16
|
-
|
17
|
-
def cached_load_config(func: T) -> T:
|
18
|
-
"""
|
19
|
-
A decorator to cache the result of a config-loading function.
|
20
|
-
Uses LRU cache with maxsize=1.
|
21
|
-
"""
|
22
|
-
cached = lru_cache(maxsize=1)(func)
|
23
|
-
wrapped = wraps(func)(cached)
|
24
|
-
return cast(T, wrapped)
|
@@ -1,22 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
"""
|
3
|
-
novel_downloader.utils.fontocr
|
4
|
-
------------------------------
|
5
|
-
|
6
|
-
Utilities for font-based OCR, primarily used to decode custom font obfuscation
|
7
|
-
|
8
|
-
Supports:
|
9
|
-
- Font rendering and perceptual hash matching
|
10
|
-
- PaddleOCR-based character recognition
|
11
|
-
- Frequency-based scoring for ambiguous results
|
12
|
-
- Debugging and font mapping persistence
|
13
|
-
|
14
|
-
Exposes the selected OCR engine version via `FontOCR`.
|
15
|
-
"""
|
16
|
-
|
17
|
-
__all__ = ["FontOCR"]
|
18
|
-
__version__ = "3.0"
|
19
|
-
|
20
|
-
# from .ocr_v1 import FontOCRV1 as FontOCR
|
21
|
-
# from .ocr_v2 import FontOCRV2 as FontOCR
|
22
|
-
from .ocr_v3 import FontOCRV3 as FontOCR
|
@@ -1,280 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
"""
|
3
|
-
novel_downloader.utils.fontocr.hash_store
|
4
|
-
-----------------------------------------
|
5
|
-
|
6
|
-
Manage a small collection of image perceptual hashes and their labels.
|
7
|
-
Supports loading/saving to .json or .npy, and basic CRUD + search.
|
8
|
-
"""
|
9
|
-
|
10
|
-
import heapq
|
11
|
-
import json
|
12
|
-
import logging
|
13
|
-
from collections.abc import Callable
|
14
|
-
from pathlib import Path
|
15
|
-
|
16
|
-
from PIL import Image
|
17
|
-
|
18
|
-
from ..constants import DATA_DIR
|
19
|
-
from .hash_utils import HASH_DISTANCE_THRESHOLD, fast_hamming_distance, phash
|
20
|
-
|
21
|
-
logger = logging.getLogger(__name__)
|
22
|
-
HASH_STORE_FILE = DATA_DIR / "image_hashes.json"
|
23
|
-
|
24
|
-
|
25
|
-
class _BKNode:
|
26
|
-
"""
|
27
|
-
A node in a Burkhard-Keller tree (BK-Tree) for distance search.
|
28
|
-
Stores one value and a dict of children keyed by distance.
|
29
|
-
"""
|
30
|
-
|
31
|
-
__slots__ = ("value", "children")
|
32
|
-
|
33
|
-
def __init__(self, value: int):
|
34
|
-
self.value = value
|
35
|
-
self.children: dict[int, _BKNode] = {}
|
36
|
-
|
37
|
-
def add(self, h: int, dist_fn: Callable[[int, int], int]) -> None:
|
38
|
-
d = dist_fn(h, self.value)
|
39
|
-
child = self.children.get(d)
|
40
|
-
if child is not None:
|
41
|
-
child.add(h, dist_fn)
|
42
|
-
else:
|
43
|
-
self.children[d] = _BKNode(h)
|
44
|
-
|
45
|
-
def query(
|
46
|
-
self,
|
47
|
-
target: int,
|
48
|
-
threshold: int,
|
49
|
-
dist_fn: Callable[[int, int], int],
|
50
|
-
) -> list[tuple[int, int]]:
|
51
|
-
"""
|
52
|
-
Recursively collect (value, dist) pairs within threshold.
|
53
|
-
"""
|
54
|
-
d0 = dist_fn(target, self.value)
|
55
|
-
matches: list[tuple[int, int]] = []
|
56
|
-
if d0 <= threshold:
|
57
|
-
matches.append((self.value, d0))
|
58
|
-
# Only children whose edge-dist \in [d0-threshold, d0+threshold]
|
59
|
-
lower, upper = d0 - threshold, d0 + threshold
|
60
|
-
for edge, child in self.children.items():
|
61
|
-
if lower <= edge <= upper:
|
62
|
-
matches.extend(child.query(target, threshold, dist_fn))
|
63
|
-
return matches
|
64
|
-
|
65
|
-
|
66
|
-
class ImageHashStore:
|
67
|
-
"""
|
68
|
-
Store and manage image hashes grouped by label, with a BK-Tree index.
|
69
|
-
|
70
|
-
:param path: file path for persistence (".json" or ".npy")
|
71
|
-
:param auto_save: if True, every modification automatically calls save()
|
72
|
-
:param hash_func: function to compute hash from PIL.Image
|
73
|
-
:param ham_dist: function to compute Hamming distance between two hashes
|
74
|
-
"""
|
75
|
-
|
76
|
-
def __init__(
|
77
|
-
self,
|
78
|
-
path: str | Path = HASH_STORE_FILE,
|
79
|
-
auto_save: bool = False,
|
80
|
-
hash_func: Callable[[Image.Image], int] = phash,
|
81
|
-
ham_dist: Callable[[int, int], int] = fast_hamming_distance,
|
82
|
-
threshold: int = HASH_DISTANCE_THRESHOLD,
|
83
|
-
) -> None:
|
84
|
-
self._path = Path(path)
|
85
|
-
self._auto = auto_save
|
86
|
-
self._hf = hash_func
|
87
|
-
self._hd = ham_dist
|
88
|
-
self._th = threshold
|
89
|
-
|
90
|
-
# label -> set of hashes
|
91
|
-
self._hash: dict[str, set[int]] = {}
|
92
|
-
# hash -> list of labels (for reverse lookup)
|
93
|
-
self._hash_to_labels: dict[int, list[str]] = {}
|
94
|
-
# root of BK-Tree (or None if empty)
|
95
|
-
self._bk_root: _BKNode | None = None
|
96
|
-
|
97
|
-
self.load()
|
98
|
-
|
99
|
-
def load(self) -> None:
|
100
|
-
"""Load store from disk and rebuild BK-Tree index."""
|
101
|
-
if not self._path.exists():
|
102
|
-
self._hash.clear()
|
103
|
-
logger.debug(
|
104
|
-
"[ImageHashStore] No file found at %s, starting empty.", self._path
|
105
|
-
)
|
106
|
-
return
|
107
|
-
|
108
|
-
txt = self._path.read_text(encoding="utf-8")
|
109
|
-
obj = json.loads(txt) or {}
|
110
|
-
self._hash = {lbl: set(obj.get(lbl, [])) for lbl in obj}
|
111
|
-
|
112
|
-
# rebuild reverse map and BK-Tree
|
113
|
-
self._hash_to_labels.clear()
|
114
|
-
for lbl, hs in self._hash.items():
|
115
|
-
for h in hs:
|
116
|
-
self._hash_to_labels.setdefault(h, []).append(lbl)
|
117
|
-
logger.debug(
|
118
|
-
"[ImageHashStore] Loaded hash store from %s with %d hashes",
|
119
|
-
self._path,
|
120
|
-
sum(len(v) for v in self._hash.values()),
|
121
|
-
)
|
122
|
-
|
123
|
-
self._build_index()
|
124
|
-
|
125
|
-
def _build_index(self) -> None:
|
126
|
-
"""Construct a BK-Tree over all stored hashes."""
|
127
|
-
self._bk_root = None
|
128
|
-
for h in self._hash_to_labels:
|
129
|
-
if self._bk_root is None:
|
130
|
-
self._bk_root = _BKNode(h)
|
131
|
-
else:
|
132
|
-
self._bk_root.add(h, self._hd)
|
133
|
-
logger.debug(
|
134
|
-
"[ImageHashStore] BK-tree index built with %d unique hashes",
|
135
|
-
len(self._hash_to_labels),
|
136
|
-
)
|
137
|
-
|
138
|
-
def save(self) -> None:
|
139
|
-
"""Persist current store to disk."""
|
140
|
-
self._path.parent.mkdir(parents=True, exist_ok=True)
|
141
|
-
data = {lbl: list(s) for lbl, s in self._hash.items()}
|
142
|
-
txt = json.dumps(data, ensure_ascii=False, indent=2)
|
143
|
-
self._path.write_text(txt, encoding="utf-8")
|
144
|
-
logger.debug("[ImageHashStore] Saved hash store to %s", self._path)
|
145
|
-
|
146
|
-
def _maybe_save(self) -> None:
|
147
|
-
if self._auto:
|
148
|
-
self.save()
|
149
|
-
|
150
|
-
def add_image(self, img_path: str | Path, label: str) -> int:
|
151
|
-
"""
|
152
|
-
Compute hash for the given image and add it under `label`.
|
153
|
-
Updates BK-Tree index incrementally.
|
154
|
-
"""
|
155
|
-
img = Image.open(img_path).convert("L")
|
156
|
-
h = self._hf(img)
|
157
|
-
self._hash.setdefault(label, set()).add(h)
|
158
|
-
self._hash_to_labels.setdefault(h, []).append(label)
|
159
|
-
# insert into BK-Tree
|
160
|
-
if self._bk_root is None:
|
161
|
-
self._bk_root = _BKNode(h)
|
162
|
-
else:
|
163
|
-
self._bk_root.add(h, self._hd)
|
164
|
-
logger.debug("[ImageHashStore] Added hash %d under label '%s'", h, label)
|
165
|
-
self._maybe_save()
|
166
|
-
return h
|
167
|
-
|
168
|
-
def add_from_map(self, map_path: str | Path) -> None:
|
169
|
-
"""
|
170
|
-
Load a JSON file of the form { "image_path": "label", ... }
|
171
|
-
and add each entry.
|
172
|
-
"""
|
173
|
-
map_path = Path(map_path)
|
174
|
-
text = map_path.read_text(encoding="utf-8")
|
175
|
-
mapping = json.loads(text)
|
176
|
-
for rel_img_path, lbl in mapping.items():
|
177
|
-
img_path = (map_path.parent / rel_img_path).resolve()
|
178
|
-
try:
|
179
|
-
self.add_image(img_path, lbl)
|
180
|
-
except Exception as e:
|
181
|
-
logger.warning(
|
182
|
-
"[ImageHashStore] Failed to add image '%s': %s", img_path, str(e)
|
183
|
-
)
|
184
|
-
continue
|
185
|
-
|
186
|
-
def labels(self) -> list[str]:
|
187
|
-
"""Return a sorted list of all labels in the store."""
|
188
|
-
return sorted(self._hash.keys())
|
189
|
-
|
190
|
-
def hashes(self, label: str) -> set[int]:
|
191
|
-
"""Return the set of hashes for a given `label` (empty set if none)."""
|
192
|
-
return set(self._hash.get(label, ()))
|
193
|
-
|
194
|
-
def remove_label(self, label: str) -> None:
|
195
|
-
"""Remove all hashes associated with `label`."""
|
196
|
-
if label in self._hash:
|
197
|
-
del self._hash[label]
|
198
|
-
logger.debug("[ImageHashStore] Removed label '%s'", label)
|
199
|
-
self._maybe_save()
|
200
|
-
|
201
|
-
def remove_hash(self, label: str, this: int | str | Path) -> bool:
|
202
|
-
"""
|
203
|
-
Remove a specific hash under `label`.
|
204
|
-
`this` can be:
|
205
|
-
- an integer hash
|
206
|
-
- a Path (image file) -> will compute its hash then remove
|
207
|
-
Returns True if something was removed.
|
208
|
-
"""
|
209
|
-
if label not in self._hash:
|
210
|
-
return False
|
211
|
-
|
212
|
-
h = None
|
213
|
-
if isinstance(this, (str | Path)):
|
214
|
-
try:
|
215
|
-
img = Image.open(this).convert("L")
|
216
|
-
h = self._hf(img)
|
217
|
-
except Exception as e:
|
218
|
-
logger.warning(
|
219
|
-
"[ImageHashStore] Could not open image '%s': %s", this, str(e)
|
220
|
-
)
|
221
|
-
return False
|
222
|
-
else:
|
223
|
-
h = int(this)
|
224
|
-
|
225
|
-
if h in self._hash[label]:
|
226
|
-
self._hash[label].remove(h)
|
227
|
-
logger.debug("[ImageHashStore] Removed hash %d from label '%s'", h, label)
|
228
|
-
self._maybe_save()
|
229
|
-
return True
|
230
|
-
return False
|
231
|
-
|
232
|
-
def query(
|
233
|
-
self,
|
234
|
-
target: int | str | Path | Image.Image,
|
235
|
-
k: int = 1,
|
236
|
-
threshold: int | None = None,
|
237
|
-
) -> list[tuple[str, float]]:
|
238
|
-
"""
|
239
|
-
Find up to `k` distinct labels whose stored hashes are most similar
|
240
|
-
to `target` within `threshold`. Returns a list of (label, score),
|
241
|
-
sorted by descending score. Each label appears at most once.
|
242
|
-
|
243
|
-
:param target: Image path / int hash / PIL.Image
|
244
|
-
:param k: number of labels to return (default=1)
|
245
|
-
:param threshold: Hamming distance cutoff (default=self._th)
|
246
|
-
"""
|
247
|
-
if threshold is None:
|
248
|
-
threshold = self._th
|
249
|
-
|
250
|
-
# compute target hash
|
251
|
-
if isinstance(target, Image.Image):
|
252
|
-
img = target.convert("L")
|
253
|
-
thash = self._hf(img)
|
254
|
-
elif isinstance(target, (str | Path)):
|
255
|
-
img = Image.open(target).convert("L")
|
256
|
-
thash = self._hf(img)
|
257
|
-
else:
|
258
|
-
thash = int(target)
|
259
|
-
|
260
|
-
if self._bk_root is None:
|
261
|
-
return []
|
262
|
-
|
263
|
-
# find all (hash,dist) within threshold
|
264
|
-
matches = self._bk_root.query(thash, threshold, self._hd)
|
265
|
-
|
266
|
-
# collapse to one best dist per label
|
267
|
-
best_per_label: dict[str, float] = {}
|
268
|
-
h2l = self._hash_to_labels
|
269
|
-
for h, dist in matches:
|
270
|
-
for lbl in h2l.get(h, ()):
|
271
|
-
score = 1.0 - dist / threshold
|
272
|
-
prev = best_per_label.get(lbl)
|
273
|
-
if prev is None or score > prev:
|
274
|
-
best_per_label[lbl] = score
|
275
|
-
|
276
|
-
top_k = heapq.nsmallest(k, best_per_label.items(), key=lambda x: x[1])
|
277
|
-
return top_k
|
278
|
-
|
279
|
-
|
280
|
-
img_hash_store = ImageHashStore()
|