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
@@ -10,14 +10,12 @@ Pure utility functions for EPUB assembly, including:
|
|
10
10
|
"""
|
11
11
|
|
12
12
|
import hashlib
|
13
|
+
from html import escape
|
13
14
|
from pathlib import Path
|
14
15
|
|
15
|
-
from lxml import etree, html
|
16
|
-
|
17
16
|
from .constants import (
|
18
17
|
CONTAINER_TEMPLATE,
|
19
18
|
IMAGE_FOLDER,
|
20
|
-
PRETTY_PRINT_FLAG,
|
21
19
|
ROOT_PATH,
|
22
20
|
)
|
23
21
|
|
@@ -67,42 +65,43 @@ def build_book_intro(
|
|
67
65
|
|
68
66
|
:return: A HTML string for inclusion in `intro.xhtml`
|
69
67
|
"""
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
68
|
+
lines = []
|
69
|
+
|
70
|
+
lines.append("<div>")
|
71
|
+
lines.append("<h1>书籍简介</h1>")
|
72
|
+
lines.append('<div class="intro-info">')
|
73
|
+
lines.append("<ul>")
|
74
|
+
|
75
|
+
name_val = f"《{book_name}》" if book_name else ""
|
76
|
+
subj_val = ", ".join(subject) if subject else ""
|
77
|
+
|
78
|
+
li_lines = [
|
79
|
+
_li_line("书名", name_val),
|
80
|
+
_li_line("作者", author),
|
81
|
+
_li_line("分类", subj_val),
|
82
|
+
_li_line("字数", word_count),
|
83
|
+
_li_line("状态", serial_status),
|
84
|
+
]
|
85
|
+
for li in li_lines:
|
86
|
+
if li:
|
87
|
+
lines.append(li)
|
88
|
+
|
89
|
+
lines.append("</ul>")
|
90
|
+
lines.append("</div>")
|
91
91
|
|
92
|
-
|
92
|
+
if summary:
|
93
|
+
lines.append('<p class="new-page-after"></p>')
|
94
|
+
lines.append("<h2>简介</h2>")
|
95
|
+
lines.append('<div class="intro-summary">')
|
93
96
|
for line in summary.splitlines():
|
94
|
-
|
95
|
-
if not
|
97
|
+
s = line.strip()
|
98
|
+
if not s:
|
96
99
|
continue
|
97
|
-
|
98
|
-
|
100
|
+
lines.append(f"<p>{escape(s, quote=True)}</p>")
|
101
|
+
lines.append("</div>")
|
99
102
|
|
100
|
-
|
101
|
-
|
102
|
-
pretty_print=PRETTY_PRINT_FLAG,
|
103
|
-
encoding="unicode",
|
104
|
-
)
|
105
|
-
return html_string
|
103
|
+
lines.append("</div>")
|
104
|
+
return "\n".join(lines)
|
106
105
|
|
107
106
|
|
108
107
|
def build_volume_intro(
|
@@ -122,77 +121,45 @@ def build_volume_intro(
|
|
122
121
|
:param volume_intro_text: multiline intro text for this volume
|
123
122
|
:return: A HTML string for inclusion in `vol_<n>.xhtml`
|
124
123
|
"""
|
125
|
-
root = html.Element("div")
|
126
|
-
|
127
|
-
# Break the title into two lines if possible
|
128
124
|
line1, line2 = _split_volume_title(volume_title)
|
129
125
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
h1 = etree.SubElement(header, "h1", {"class": "vol-title-main"})
|
137
|
-
h1.text = line1
|
138
|
-
|
139
|
-
# Bottom decorative border (flipped)
|
140
|
-
header.append(_make_vol_border_img(flip=True))
|
141
|
-
|
142
|
-
# Subtitle (if any)
|
126
|
+
lines = []
|
127
|
+
lines.append("<div>")
|
128
|
+
lines.append('<div class="vol-header">')
|
129
|
+
lines.append(_vol_border_div_str(flip=False))
|
130
|
+
lines.append(f'<h1 class="vol-title-main">{escape(line1, quote=True)}</h1>')
|
131
|
+
lines.append(_vol_border_div_str(flip=True))
|
143
132
|
if line2:
|
144
|
-
|
145
|
-
|
133
|
+
lines.append(f'<h2 class="vol-title-sub">{escape(line2, quote=True)}</h2>')
|
134
|
+
lines.append("</div>")
|
146
135
|
|
147
|
-
# Intro text paragraphs
|
148
136
|
if volume_intro_text:
|
149
|
-
|
150
|
-
|
151
|
-
vol_div = etree.SubElement(root, "div", {"class": "vol-intro-text"})
|
137
|
+
lines.append('<p class="new-page-after"></p>')
|
138
|
+
lines.append('<div class="vol-intro-text">')
|
152
139
|
for line in volume_intro_text.splitlines():
|
153
|
-
|
154
|
-
if not
|
140
|
+
s = line.strip()
|
141
|
+
if not s:
|
155
142
|
continue
|
156
|
-
|
157
|
-
|
143
|
+
lines.append(f"<p>{escape(s, quote=True)}</p>")
|
144
|
+
lines.append("</div>")
|
158
145
|
|
159
|
-
|
160
|
-
|
161
|
-
pretty_print=PRETTY_PRINT_FLAG,
|
162
|
-
encoding="unicode",
|
163
|
-
)
|
164
|
-
return html_string
|
146
|
+
lines.append("</div>")
|
147
|
+
return "\n".join(lines)
|
165
148
|
|
166
149
|
|
167
|
-
def
|
168
|
-
|
169
|
-
|
170
|
-
""
|
171
|
-
if value:
|
172
|
-
li = etree.SubElement(ul, "li")
|
173
|
-
li.text = f"{label}: {value}"
|
150
|
+
def _li_line(label: str, value: str) -> str:
|
151
|
+
if not value:
|
152
|
+
return ""
|
153
|
+
return f"<li>{escape(label, quote=True)}: {escape(value, quote=True)}</li>"
|
174
154
|
|
175
155
|
|
176
|
-
def
|
177
|
-
"""
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
if flip:
|
183
|
-
classes.append("flip")
|
184
|
-
cls = " ".join(classes)
|
185
|
-
|
186
|
-
div = html.Element("div", {"class": cls})
|
187
|
-
etree.SubElement(
|
188
|
-
div,
|
189
|
-
"img",
|
190
|
-
{
|
191
|
-
"src": f"../{IMAGE_FOLDER}/volume_border.png",
|
192
|
-
"alt": "",
|
193
|
-
},
|
156
|
+
def _vol_border_div_str(flip: bool = False) -> str:
|
157
|
+
classes = "vol-border" + (" flip" if flip else "")
|
158
|
+
return (
|
159
|
+
f'<div class="{classes}">'
|
160
|
+
f'<img src="../{IMAGE_FOLDER}/volume_border.png" alt="">'
|
161
|
+
f"</div>"
|
194
162
|
)
|
195
|
-
return div
|
196
163
|
|
197
164
|
|
198
165
|
def _split_volume_title(volume_title: str) -> tuple[str, str]:
|
@@ -202,10 +169,10 @@ def _split_volume_title(volume_title: str) -> tuple[str, str]:
|
|
202
169
|
:param volume_title: Original volume title string.
|
203
170
|
:return: Tuple of (line1, line2)
|
204
171
|
"""
|
205
|
-
if "
|
206
|
-
parts = volume_title.split(" ", 1)
|
207
|
-
elif "-" in volume_title:
|
172
|
+
if "-" in volume_title:
|
208
173
|
parts = volume_title.split("-", 1)
|
174
|
+
elif " " in volume_title:
|
175
|
+
parts = volume_title.split(" ", 1)
|
209
176
|
else:
|
210
177
|
return volume_title, ""
|
211
178
|
|
@@ -4,35 +4,14 @@ novel_downloader.utils.file_utils
|
|
4
4
|
---------------------------------
|
5
5
|
|
6
6
|
High-level file I/O utility re-exports for convenience.
|
7
|
-
|
8
|
-
This module aggregates commonly used low-level file utilities such as:
|
9
|
-
- Path sanitization (for safe filenames)
|
10
|
-
- Text normalization (e.g. Windows/Linux line endings)
|
11
|
-
- JSON, plain text, and binary file reading/writing
|
12
|
-
|
13
|
-
Included utilities:
|
14
|
-
- sanitize_filename: remove invalid characters from filenames
|
15
|
-
- normalize_txt_line_endings: standardize line endings in text files
|
16
|
-
- save_as_json / save_as_txt: write dict or text to file
|
17
|
-
- read_text_file / read_json_file / read_binary_file: load content from file
|
18
7
|
"""
|
19
8
|
|
20
9
|
__all__ = [
|
21
10
|
"sanitize_filename",
|
22
|
-
"
|
23
|
-
"save_as_txt",
|
24
|
-
"read_text_file",
|
25
|
-
"read_json_file",
|
26
|
-
"read_binary_file",
|
11
|
+
"write_file",
|
27
12
|
"normalize_txt_line_endings",
|
28
13
|
]
|
29
14
|
|
30
|
-
from .io import
|
31
|
-
read_binary_file,
|
32
|
-
read_json_file,
|
33
|
-
read_text_file,
|
34
|
-
save_as_json,
|
35
|
-
save_as_txt,
|
36
|
-
)
|
15
|
+
from .io import write_file
|
37
16
|
from .normalize import normalize_txt_line_endings
|
38
17
|
from .sanitize import sanitize_filename
|
@@ -3,21 +3,10 @@
|
|
3
3
|
novel_downloader.utils.file_utils.io
|
4
4
|
------------------------------------
|
5
5
|
|
6
|
-
File I/O utilities for reading and writing
|
7
|
-
|
8
|
-
Includes:
|
9
|
-
- Safe, atomic file saving with optional overwrite and auto-renaming
|
10
|
-
- JSON pretty-printing with size-aware formatting
|
11
|
-
- Simple helpers for reading files with fallback and logging
|
6
|
+
File I/O utilities for reading and writing data.
|
12
7
|
"""
|
13
8
|
|
14
|
-
__all__ = [
|
15
|
-
"save_as_txt",
|
16
|
-
"save_as_json",
|
17
|
-
"read_text_file",
|
18
|
-
"read_json_file",
|
19
|
-
"read_binary_file",
|
20
|
-
]
|
9
|
+
__all__ = ["write_file"]
|
21
10
|
|
22
11
|
import json
|
23
12
|
import logging
|
@@ -46,7 +35,7 @@ def _get_non_conflicting_path(path: Path) -> Path:
|
|
46
35
|
return new_path
|
47
36
|
|
48
37
|
|
49
|
-
def
|
38
|
+
def write_file(
|
50
39
|
content: str | bytes | dict[Any, Any] | list[Any] | Any,
|
51
40
|
filepath: str | Path,
|
52
41
|
write_mode: str = "w",
|
@@ -115,102 +104,3 @@ def _write_file(
|
|
115
104
|
except Exception as exc:
|
116
105
|
logger.warning("[file] Error writing %r: %s", path, exc)
|
117
106
|
return None
|
118
|
-
|
119
|
-
|
120
|
-
def save_as_txt(
|
121
|
-
content: str,
|
122
|
-
filepath: str | Path,
|
123
|
-
*,
|
124
|
-
encoding: str = "utf-8",
|
125
|
-
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
126
|
-
) -> Path | None:
|
127
|
-
"""
|
128
|
-
Save plain text content to the given file path.
|
129
|
-
|
130
|
-
:param content: Text content to write.
|
131
|
-
:param filepath: Destination file path.
|
132
|
-
:param encoding: Text encoding to use (default: 'utf-8').
|
133
|
-
:param on_exist: How to handle existing files: 'overwrite', 'skip', or 'rename'.
|
134
|
-
:return: Path if writing succeeds, None otherwise.
|
135
|
-
"""
|
136
|
-
return _write_file(
|
137
|
-
content=content,
|
138
|
-
filepath=filepath,
|
139
|
-
write_mode="w",
|
140
|
-
on_exist=on_exist,
|
141
|
-
dump_json=False,
|
142
|
-
encoding=encoding,
|
143
|
-
)
|
144
|
-
|
145
|
-
|
146
|
-
def save_as_json(
|
147
|
-
content: Any,
|
148
|
-
filepath: str | Path,
|
149
|
-
*,
|
150
|
-
encoding: str = "utf-8",
|
151
|
-
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
152
|
-
) -> Path | None:
|
153
|
-
"""
|
154
|
-
Save JSON-serializable content to the given file path.
|
155
|
-
|
156
|
-
:param content: Data to write as JSON.
|
157
|
-
:param filepath: Destination file path.
|
158
|
-
:param encoding: Text encoding to use (default: 'utf-8').
|
159
|
-
:param on_exist: How to handle existing files: 'overwrite', 'skip', or 'rename'.
|
160
|
-
:return: Path if writing succeeds, None otherwise.
|
161
|
-
"""
|
162
|
-
return _write_file(
|
163
|
-
content=content,
|
164
|
-
filepath=filepath,
|
165
|
-
write_mode="w",
|
166
|
-
on_exist=on_exist,
|
167
|
-
dump_json=True,
|
168
|
-
encoding=encoding,
|
169
|
-
)
|
170
|
-
|
171
|
-
|
172
|
-
def read_text_file(filepath: str | Path, encoding: str = "utf-8") -> str | None:
|
173
|
-
"""
|
174
|
-
Read a UTF-8 text file.
|
175
|
-
|
176
|
-
:param filepath: Path to file.
|
177
|
-
:param encoding: Encoding to use.
|
178
|
-
:return: Text content or None on failure.
|
179
|
-
"""
|
180
|
-
path = Path(filepath)
|
181
|
-
try:
|
182
|
-
return path.read_text(encoding=encoding)
|
183
|
-
except Exception as e:
|
184
|
-
logger.warning("[file] Failed to read %r: %s", path, e)
|
185
|
-
return None
|
186
|
-
|
187
|
-
|
188
|
-
def read_json_file(filepath: str | Path, encoding: str = "utf-8") -> Any | None:
|
189
|
-
"""
|
190
|
-
Read a JSON file and parse it into Python objects.
|
191
|
-
|
192
|
-
:param filepath: Path to file.
|
193
|
-
:param encoding: Encoding to use.
|
194
|
-
:return: Python object or None on failure.
|
195
|
-
"""
|
196
|
-
path = Path(filepath)
|
197
|
-
try:
|
198
|
-
return json.loads(path.read_text(encoding=encoding))
|
199
|
-
except Exception as e:
|
200
|
-
logger.warning("[file] Failed to read %r: %s", path, e)
|
201
|
-
return None
|
202
|
-
|
203
|
-
|
204
|
-
def read_binary_file(filepath: str | Path) -> bytes | None:
|
205
|
-
"""
|
206
|
-
Read a binary file and return its content as bytes.
|
207
|
-
|
208
|
-
:param filepath: Path to file.
|
209
|
-
:return: Bytes or None on failure.
|
210
|
-
"""
|
211
|
-
path = Path(filepath)
|
212
|
-
try:
|
213
|
-
return path.read_bytes()
|
214
|
-
except Exception as e:
|
215
|
-
logger.warning("[file] Failed to read %r: %s", path, e)
|
216
|
-
return None
|
@@ -5,10 +5,6 @@ novel_downloader.utils.file_utils.sanitize
|
|
5
5
|
|
6
6
|
Utility functions for cleaning and validating filenames for safe use
|
7
7
|
on different operating systems.
|
8
|
-
|
9
|
-
This module provides a cross-platform `sanitize_filename` function
|
10
|
-
that replaces or removes illegal characters from filenames, trims
|
11
|
-
lengths, and avoids reserved names on Windows systems.
|
12
8
|
"""
|
13
9
|
|
14
10
|
__all__ = ["sanitize_filename"]
|
@@ -0,0 +1,207 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.utils.fontocr
|
4
|
+
------------------------------
|
5
|
+
|
6
|
+
This class provides utility methods for optical character recognition (OCR),
|
7
|
+
primarily used for decrypting custom font encryption.
|
8
|
+
"""
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"FontOCR",
|
12
|
+
"get_font_ocr",
|
13
|
+
]
|
14
|
+
__version__ = "4.0"
|
15
|
+
|
16
|
+
import logging
|
17
|
+
from collections.abc import Generator
|
18
|
+
from typing import Any, TypeVar
|
19
|
+
|
20
|
+
import numpy as np
|
21
|
+
from paddleocr import TextRecognition # takes 5 ~ 12 sec to init
|
22
|
+
from PIL import Image, ImageDraw, ImageFont
|
23
|
+
from PIL.Image import Transpose
|
24
|
+
|
25
|
+
T = TypeVar("T")
|
26
|
+
logger = logging.getLogger(__name__)
|
27
|
+
|
28
|
+
|
29
|
+
class FontOCR:
|
30
|
+
"""
|
31
|
+
Version 4 of the FontOCR utility.
|
32
|
+
"""
|
33
|
+
|
34
|
+
def __init__(
|
35
|
+
self,
|
36
|
+
model_name: str | None = None,
|
37
|
+
model_dir: str | None = None,
|
38
|
+
input_shape: tuple[int, int, int] | None = None,
|
39
|
+
device: str | None = None,
|
40
|
+
precision: str = "fp32",
|
41
|
+
cpu_threads: int = 10,
|
42
|
+
batch_size: int = 32,
|
43
|
+
threshold: float = 0.0,
|
44
|
+
**kwargs: Any,
|
45
|
+
) -> None:
|
46
|
+
"""
|
47
|
+
Initialize a FontOCR instance.
|
48
|
+
|
49
|
+
:param batch_size: batch size for OCR inference (minimum 1)
|
50
|
+
:param ocr_weight: weight factor for OCR-based prediction scores
|
51
|
+
:param vec_weight: weight factor for vector-based similarity scores
|
52
|
+
:param threshold: minimum confidence threshold for predictions [0.0-1.0]
|
53
|
+
:param kwargs: reserved for future extensions
|
54
|
+
"""
|
55
|
+
self._batch_size = batch_size
|
56
|
+
self._threshold = threshold
|
57
|
+
self._ocr_model = TextRecognition(
|
58
|
+
model_name=model_name,
|
59
|
+
model_dir=model_dir,
|
60
|
+
input_shape=input_shape,
|
61
|
+
device=device,
|
62
|
+
precision=precision,
|
63
|
+
cpu_threads=cpu_threads,
|
64
|
+
)
|
65
|
+
|
66
|
+
def predict(
|
67
|
+
self,
|
68
|
+
images: list[np.ndarray],
|
69
|
+
top_k: int = 1,
|
70
|
+
) -> list[list[tuple[str, float]]]:
|
71
|
+
"""
|
72
|
+
Run OCR on input images.
|
73
|
+
|
74
|
+
:param images: list of np.ndarray objects to predict
|
75
|
+
:param top_k: number of top candidates to return per image
|
76
|
+
:return: list of lists containing (character, score)
|
77
|
+
"""
|
78
|
+
return [
|
79
|
+
[(pred.get("rec_text"), pred.get("rec_score"))]
|
80
|
+
for pred in self._ocr_model.predict(images, batch_size=self._batch_size)
|
81
|
+
]
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def render_char_image(
|
85
|
+
char: str,
|
86
|
+
render_font: ImageFont.FreeTypeFont,
|
87
|
+
is_reflect: bool = False,
|
88
|
+
size: int = 64,
|
89
|
+
) -> Image.Image | None:
|
90
|
+
"""
|
91
|
+
Render a single character into an RGB square image.
|
92
|
+
|
93
|
+
:param char: character to render
|
94
|
+
:param render_font: FreeTypeFont instance to render with
|
95
|
+
:param is_reflect: if True, flip the image horizontally
|
96
|
+
:param size: output image size (width and height in pixels)
|
97
|
+
:return: rendered PIL.Image in RGB or None if blank
|
98
|
+
"""
|
99
|
+
# img = Image.new("L", (size, size), color=255)
|
100
|
+
img = Image.new("RGB", (size, size), color=(255, 255, 255))
|
101
|
+
draw = ImageDraw.Draw(img)
|
102
|
+
bbox = draw.textbbox((0, 0), char, font=render_font)
|
103
|
+
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
104
|
+
x = (size - w) // 2 - bbox[0]
|
105
|
+
y = (size - h) // 2 - bbox[1]
|
106
|
+
draw.text((x, y), char, fill=0, font=render_font)
|
107
|
+
if is_reflect:
|
108
|
+
img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
|
109
|
+
|
110
|
+
img_np = np.array(img)
|
111
|
+
if np.unique(img_np).size == 1:
|
112
|
+
return None
|
113
|
+
|
114
|
+
return img
|
115
|
+
|
116
|
+
@staticmethod
|
117
|
+
def render_char_image_array(
|
118
|
+
char: str,
|
119
|
+
render_font: ImageFont.FreeTypeFont,
|
120
|
+
is_reflect: bool = False,
|
121
|
+
size: int = 64,
|
122
|
+
) -> np.ndarray | None:
|
123
|
+
"""
|
124
|
+
Render a single character into an RGB square image.
|
125
|
+
|
126
|
+
:param char: character to render
|
127
|
+
:param render_font: FreeTypeFont instance to render with
|
128
|
+
:param is_reflect: if True, flip the image horizontally
|
129
|
+
:param size: output image size (width and height in pixels)
|
130
|
+
:return: rendered image as np.ndarray in RGB or None if blank
|
131
|
+
"""
|
132
|
+
# img = Image.new("L", (size, size), color=255)
|
133
|
+
img = Image.new("RGB", (size, size), color=(255, 255, 255))
|
134
|
+
draw = ImageDraw.Draw(img)
|
135
|
+
bbox = draw.textbbox((0, 0), char, font=render_font)
|
136
|
+
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
137
|
+
x = (size - w) // 2 - bbox[0]
|
138
|
+
y = (size - h) // 2 - bbox[1]
|
139
|
+
draw.text((x, y), char, fill=0, font=render_font)
|
140
|
+
if is_reflect:
|
141
|
+
img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
|
142
|
+
|
143
|
+
img_np = np.array(img)
|
144
|
+
if np.unique(img_np).size == 1:
|
145
|
+
return None
|
146
|
+
|
147
|
+
return img_np
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def render_text_image(
|
151
|
+
text: str,
|
152
|
+
font: ImageFont.FreeTypeFont,
|
153
|
+
cell_size: int = 64,
|
154
|
+
chars_per_line: int = 16,
|
155
|
+
) -> Image.Image:
|
156
|
+
"""
|
157
|
+
Render a string into a image.
|
158
|
+
"""
|
159
|
+
# import textwrap
|
160
|
+
# lines = textwrap.wrap(text, width=chars_per_line) or [""]
|
161
|
+
lines = [
|
162
|
+
text[i : i + chars_per_line] for i in range(0, len(text), chars_per_line)
|
163
|
+
] or [""]
|
164
|
+
img_w = cell_size * chars_per_line
|
165
|
+
img_h = cell_size * len(lines)
|
166
|
+
|
167
|
+
# img = Image.new("L", (img_w, img_h), color=255)
|
168
|
+
img = Image.new("RGB", (img_w, img_h), color=(255, 255, 255))
|
169
|
+
draw = ImageDraw.Draw(img)
|
170
|
+
for row, line in enumerate(lines):
|
171
|
+
for col, ch in enumerate(line):
|
172
|
+
x = (col + 0.5) * cell_size
|
173
|
+
y = (row + 0.5) * cell_size
|
174
|
+
draw.text((x, y), ch, font=font, fill=0, anchor="mm")
|
175
|
+
|
176
|
+
return img
|
177
|
+
|
178
|
+
@staticmethod
|
179
|
+
def _chunked(seq: list[T], size: int) -> Generator[list[T], None, None]:
|
180
|
+
"""
|
181
|
+
Yield successive chunks of `seq` of length `size`.
|
182
|
+
"""
|
183
|
+
for i in range(0, len(seq), size):
|
184
|
+
yield seq[i : i + size]
|
185
|
+
|
186
|
+
|
187
|
+
_font_ocr: FontOCR | None = None
|
188
|
+
|
189
|
+
|
190
|
+
def get_font_ocr(
|
191
|
+
model_name: str | None = None,
|
192
|
+
model_dir: str | None = None,
|
193
|
+
input_shape: tuple[int, int, int] | None = None,
|
194
|
+
batch_size: int = 32,
|
195
|
+
) -> FontOCR:
|
196
|
+
"""
|
197
|
+
Return the singleton FontOCR, initializing it on first use.
|
198
|
+
"""
|
199
|
+
global _font_ocr
|
200
|
+
if _font_ocr is None:
|
201
|
+
_font_ocr = FontOCR(
|
202
|
+
model_name=model_name,
|
203
|
+
model_dir=model_dir,
|
204
|
+
input_shape=input_shape,
|
205
|
+
batch_size=batch_size,
|
206
|
+
)
|
207
|
+
return _font_ocr
|
novel_downloader/utils/logger.py
CHANGED
@@ -14,11 +14,9 @@ from datetime import datetime
|
|
14
14
|
from logging.handlers import TimedRotatingFileHandler
|
15
15
|
from pathlib import Path
|
16
16
|
|
17
|
-
from novel_downloader.models import LogLevel
|
18
|
-
|
19
17
|
from .constants import LOGGER_DIR, LOGGER_NAME
|
20
18
|
|
21
|
-
LOG_LEVELS: dict[
|
19
|
+
LOG_LEVELS: dict[str, int] = {
|
22
20
|
"DEBUG": logging.DEBUG,
|
23
21
|
"INFO": logging.INFO,
|
24
22
|
"WARNING": logging.WARNING,
|
@@ -28,19 +26,16 @@ LOG_LEVELS: dict[LogLevel, int] = {
|
|
28
26
|
|
29
27
|
def setup_logging(
|
30
28
|
log_filename_prefix: str | None = None,
|
31
|
-
log_level:
|
29
|
+
log_level: str | None = None,
|
32
30
|
log_dir: str | Path | None = None,
|
33
31
|
) -> logging.Logger:
|
34
32
|
"""
|
35
33
|
Create and configure a logger for both console and rotating file output.
|
36
34
|
|
37
35
|
:param log_filename_prefix: Prefix for the log file name.
|
38
|
-
|
39
|
-
|
40
|
-
"DEBUG", "INFO", "WARNING", or "ERROR".
|
41
|
-
Defaults to "INFO" if not specified.
|
36
|
+
:param log_level: Minimum log level to show in console
|
37
|
+
("DEBUG", "INFO", "WARNING", "ERROR")
|
42
38
|
:param log_dir: Directory where log files will be saved.
|
43
|
-
Defaults to "./logs" if not specified.
|
44
39
|
:return: A fully configured logger instance.
|
45
40
|
"""
|
46
41
|
ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
|
@@ -48,12 +43,8 @@ def setup_logging(
|
|
48
43
|
ft_logger.propagate = False
|
49
44
|
|
50
45
|
# Determine console level (default INFO)
|
51
|
-
level_str:
|
52
|
-
console_level = LOG_LEVELS.get(level_str)
|
53
|
-
if console_level is None:
|
54
|
-
raise ValueError(
|
55
|
-
f"Invalid log level: {level_str}. Must be one of {list(LOG_LEVELS.keys())}"
|
56
|
-
)
|
46
|
+
level_str: str = log_level or "INFO"
|
47
|
+
console_level: int = LOG_LEVELS.get(level_str) or logging.INFO
|
57
48
|
|
58
49
|
# Resolve log file path
|
59
50
|
log_path = Path(log_dir) if log_dir else LOGGER_DIR
|
@@ -66,8 +57,9 @@ def setup_logging(
|
|
66
57
|
log_filename = log_path / f"{log_filename_prefix}_{date_str}.log"
|
67
58
|
|
68
59
|
# Create or retrieve logger
|
69
|
-
logger = logging.getLogger()
|
60
|
+
logger = logging.getLogger(LOGGER_NAME)
|
70
61
|
logger.setLevel(logging.DEBUG) # Capture everything, filter by handlers
|
62
|
+
logger.propagate = False
|
71
63
|
|
72
64
|
# Clear existing handlers to avoid duplicate logs
|
73
65
|
if logger.hasHandlers():
|
@@ -19,7 +19,7 @@ from urllib3.util.retry import Retry
|
|
19
19
|
|
20
20
|
from .constants import DEFAULT_HEADERS
|
21
21
|
from .file_utils import sanitize_filename
|
22
|
-
from .file_utils.io import _get_non_conflicting_path,
|
22
|
+
from .file_utils.io import _get_non_conflicting_path, write_file
|
23
23
|
|
24
24
|
logger = logging.getLogger(__name__)
|
25
25
|
_DEFAULT_CHUNK_SIZE = 8192 # 8KB per chunk for streaming downloads
|
@@ -150,7 +150,7 @@ def download(
|
|
150
150
|
save_path.unlink(missing_ok=True)
|
151
151
|
return None
|
152
152
|
else:
|
153
|
-
return
|
153
|
+
return write_file(
|
154
154
|
content=resp.content,
|
155
155
|
filepath=save_path,
|
156
156
|
write_mode="wb",
|