novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/__init__.py +1 -3
- novel_downloader/cli/clean.py +21 -88
- novel_downloader/cli/config.py +26 -21
- novel_downloader/cli/download.py +79 -66
- novel_downloader/cli/export.py +17 -21
- novel_downloader/cli/main.py +1 -1
- novel_downloader/cli/search.py +62 -65
- novel_downloader/cli/ui.py +156 -0
- novel_downloader/config/__init__.py +8 -5
- novel_downloader/config/adapter.py +206 -209
- novel_downloader/config/{loader.py → file_io.py} +53 -26
- novel_downloader/core/__init__.py +5 -5
- novel_downloader/core/archived/deqixs/fetcher.py +115 -0
- novel_downloader/core/archived/deqixs/parser.py +132 -0
- novel_downloader/core/archived/deqixs/searcher.py +89 -0
- novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
- novel_downloader/core/archived/wanbengo/searcher.py +98 -0
- novel_downloader/core/archived/xshbook/searcher.py +93 -0
- novel_downloader/core/downloaders/__init__.py +3 -24
- novel_downloader/core/downloaders/base.py +49 -23
- novel_downloader/core/downloaders/common.py +191 -137
- novel_downloader/core/downloaders/qianbi.py +187 -146
- novel_downloader/core/downloaders/qidian.py +187 -141
- novel_downloader/core/downloaders/registry.py +4 -2
- novel_downloader/core/downloaders/signals.py +46 -0
- novel_downloader/core/exporters/__init__.py +3 -20
- novel_downloader/core/exporters/base.py +33 -37
- novel_downloader/core/exporters/common/__init__.py +1 -2
- novel_downloader/core/exporters/common/epub.py +15 -10
- novel_downloader/core/exporters/common/main_exporter.py +19 -12
- novel_downloader/core/exporters/common/txt.py +17 -12
- novel_downloader/core/exporters/epub_util.py +59 -29
- novel_downloader/core/exporters/linovelib/__init__.py +1 -0
- novel_downloader/core/exporters/linovelib/epub.py +23 -25
- novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
- novel_downloader/core/exporters/linovelib/txt.py +20 -14
- novel_downloader/core/exporters/qidian.py +2 -8
- novel_downloader/core/exporters/registry.py +4 -2
- novel_downloader/core/exporters/txt_util.py +7 -7
- novel_downloader/core/fetchers/__init__.py +54 -48
- novel_downloader/core/fetchers/aaatxt.py +83 -0
- novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
- novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
- novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
- novel_downloader/core/fetchers/dxmwx.py +110 -0
- novel_downloader/core/fetchers/eightnovel.py +139 -0
- novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
- novel_downloader/core/fetchers/guidaye.py +85 -0
- novel_downloader/core/fetchers/hetushu.py +92 -0
- novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
- novel_downloader/core/fetchers/ixdzs8.py +113 -0
- novel_downloader/core/fetchers/jpxs123.py +101 -0
- novel_downloader/core/fetchers/lewenn.py +83 -0
- novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
- novel_downloader/core/fetchers/piaotia.py +105 -0
- novel_downloader/core/fetchers/qbtr.py +101 -0
- novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
- novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
- novel_downloader/core/fetchers/quanben5.py +92 -0
- novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
- novel_downloader/core/fetchers/registry.py +5 -16
- novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
- novel_downloader/core/fetchers/shencou.py +106 -0
- novel_downloader/core/fetchers/shuhaige.py +84 -0
- novel_downloader/core/fetchers/tongrenquan.py +84 -0
- novel_downloader/core/fetchers/ttkan.py +95 -0
- novel_downloader/core/fetchers/wanbengo.py +83 -0
- novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
- novel_downloader/core/fetchers/xiguashuwu.py +177 -0
- novel_downloader/core/fetchers/xs63b.py +171 -0
- novel_downloader/core/fetchers/xshbook.py +85 -0
- novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
- novel_downloader/core/fetchers/yibige.py +114 -0
- novel_downloader/core/interfaces/__init__.py +1 -9
- novel_downloader/core/interfaces/downloader.py +6 -2
- novel_downloader/core/interfaces/exporter.py +7 -7
- novel_downloader/core/interfaces/fetcher.py +6 -19
- novel_downloader/core/interfaces/parser.py +7 -8
- novel_downloader/core/interfaces/searcher.py +9 -1
- novel_downloader/core/parsers/__init__.py +49 -12
- novel_downloader/core/parsers/aaatxt.py +132 -0
- novel_downloader/core/parsers/b520.py +116 -0
- novel_downloader/core/parsers/base.py +64 -12
- novel_downloader/core/parsers/biquyuedu.py +133 -0
- novel_downloader/core/parsers/dxmwx.py +162 -0
- novel_downloader/core/parsers/eightnovel.py +224 -0
- novel_downloader/core/parsers/esjzone.py +64 -69
- novel_downloader/core/parsers/guidaye.py +128 -0
- novel_downloader/core/parsers/hetushu.py +139 -0
- novel_downloader/core/parsers/i25zw.py +137 -0
- novel_downloader/core/parsers/ixdzs8.py +186 -0
- novel_downloader/core/parsers/jpxs123.py +137 -0
- novel_downloader/core/parsers/lewenn.py +142 -0
- novel_downloader/core/parsers/linovelib.py +48 -64
- novel_downloader/core/parsers/piaotia.py +189 -0
- novel_downloader/core/parsers/qbtr.py +136 -0
- novel_downloader/core/parsers/qianbi.py +48 -50
- novel_downloader/core/parsers/qidian/main_parser.py +756 -48
- novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
- novel_downloader/core/parsers/quanben5.py +103 -0
- novel_downloader/core/parsers/registry.py +5 -16
- novel_downloader/core/parsers/sfacg.py +38 -45
- novel_downloader/core/parsers/shencou.py +215 -0
- novel_downloader/core/parsers/shuhaige.py +111 -0
- novel_downloader/core/parsers/tongrenquan.py +116 -0
- novel_downloader/core/parsers/ttkan.py +132 -0
- novel_downloader/core/parsers/wanbengo.py +191 -0
- novel_downloader/core/parsers/xiaoshuowu.py +173 -0
- novel_downloader/core/parsers/xiguashuwu.py +429 -0
- novel_downloader/core/parsers/xs63b.py +161 -0
- novel_downloader/core/parsers/xshbook.py +134 -0
- novel_downloader/core/parsers/yamibo.py +87 -131
- novel_downloader/core/parsers/yibige.py +166 -0
- novel_downloader/core/searchers/__init__.py +34 -3
- novel_downloader/core/searchers/aaatxt.py +107 -0
- novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
- novel_downloader/core/searchers/base.py +112 -36
- novel_downloader/core/searchers/dxmwx.py +105 -0
- novel_downloader/core/searchers/eightnovel.py +84 -0
- novel_downloader/core/searchers/esjzone.py +43 -25
- novel_downloader/core/searchers/hetushu.py +92 -0
- novel_downloader/core/searchers/i25zw.py +93 -0
- novel_downloader/core/searchers/ixdzs8.py +107 -0
- novel_downloader/core/searchers/jpxs123.py +107 -0
- novel_downloader/core/searchers/piaotia.py +100 -0
- novel_downloader/core/searchers/qbtr.py +106 -0
- novel_downloader/core/searchers/qianbi.py +74 -40
- novel_downloader/core/searchers/quanben5.py +144 -0
- novel_downloader/core/searchers/registry.py +24 -8
- novel_downloader/core/searchers/shuhaige.py +124 -0
- novel_downloader/core/searchers/tongrenquan.py +110 -0
- novel_downloader/core/searchers/ttkan.py +92 -0
- novel_downloader/core/searchers/xiaoshuowu.py +122 -0
- novel_downloader/core/searchers/xiguashuwu.py +95 -0
- novel_downloader/core/searchers/xs63b.py +104 -0
- novel_downloader/locales/en.json +34 -85
- novel_downloader/locales/zh.json +35 -86
- novel_downloader/models/__init__.py +21 -22
- novel_downloader/models/book.py +44 -0
- novel_downloader/models/config.py +4 -37
- novel_downloader/models/login.py +1 -1
- novel_downloader/models/search.py +5 -0
- novel_downloader/resources/config/settings.toml +8 -70
- novel_downloader/resources/json/xiguashuwu.json +718 -0
- novel_downloader/utils/__init__.py +13 -24
- novel_downloader/utils/chapter_storage.py +5 -5
- novel_downloader/utils/constants.py +4 -31
- novel_downloader/utils/cookies.py +38 -35
- novel_downloader/utils/crypto_utils/__init__.py +7 -0
- novel_downloader/utils/crypto_utils/aes_util.py +90 -0
- novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
- novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
- novel_downloader/utils/crypto_utils/rc4.py +54 -0
- novel_downloader/utils/epub/__init__.py +3 -4
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +62 -21
- novel_downloader/utils/epub/documents.py +95 -201
- novel_downloader/utils/epub/models.py +8 -22
- novel_downloader/utils/epub/utils.py +73 -106
- novel_downloader/utils/file_utils/__init__.py +2 -23
- novel_downloader/utils/file_utils/io.py +53 -188
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -15
- novel_downloader/utils/fontocr/__init__.py +5 -14
- novel_downloader/utils/fontocr/core.py +216 -0
- novel_downloader/utils/fontocr/loader.py +50 -0
- novel_downloader/utils/logger.py +81 -65
- novel_downloader/utils/network.py +17 -41
- novel_downloader/utils/state.py +4 -90
- novel_downloader/utils/text_utils/__init__.py +1 -7
- novel_downloader/utils/text_utils/diff_display.py +5 -7
- novel_downloader/utils/text_utils/text_cleaner.py +39 -30
- novel_downloader/utils/text_utils/truncate_utils.py +3 -14
- novel_downloader/utils/time_utils/__init__.py +5 -11
- novel_downloader/utils/time_utils/datetime_utils.py +20 -29
- novel_downloader/utils/time_utils/sleep_utils.py +55 -49
- novel_downloader/web/__init__.py +13 -0
- novel_downloader/web/components/__init__.py +11 -0
- novel_downloader/web/components/navigation.py +35 -0
- novel_downloader/web/main.py +66 -0
- novel_downloader/web/pages/__init__.py +17 -0
- novel_downloader/web/pages/download.py +78 -0
- novel_downloader/web/pages/progress.py +147 -0
- novel_downloader/web/pages/search.py +329 -0
- novel_downloader/web/services/__init__.py +17 -0
- novel_downloader/web/services/client_dialog.py +164 -0
- novel_downloader/web/services/cred_broker.py +113 -0
- novel_downloader/web/services/cred_models.py +35 -0
- novel_downloader/web/services/task_manager.py +264 -0
- novel_downloader-2.0.1.dist-info/METADATA +172 -0
- novel_downloader-2.0.1.dist-info/RECORD +206 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
- novel_downloader/core/downloaders/biquge.py +0 -29
- novel_downloader/core/downloaders/esjzone.py +0 -29
- novel_downloader/core/downloaders/linovelib.py +0 -29
- novel_downloader/core/downloaders/sfacg.py +0 -29
- novel_downloader/core/downloaders/yamibo.py +0 -29
- novel_downloader/core/exporters/biquge.py +0 -22
- novel_downloader/core/exporters/esjzone.py +0 -22
- novel_downloader/core/exporters/qianbi.py +0 -22
- novel_downloader/core/exporters/sfacg.py +0 -22
- novel_downloader/core/exporters/yamibo.py +0 -22
- novel_downloader/core/fetchers/base/__init__.py +0 -14
- novel_downloader/core/fetchers/base/browser.py +0 -422
- novel_downloader/core/fetchers/biquge/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/browser.py +0 -209
- novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
- novel_downloader/core/fetchers/linovelib/browser.py +0 -198
- novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/browser.py +0 -326
- novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
- novel_downloader/core/fetchers/sfacg/browser.py +0 -194
- novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
- novel_downloader/core/fetchers/yamibo/browser.py +0 -234
- novel_downloader/core/parsers/biquge.py +0 -139
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
- novel_downloader/models/chapter.py +0 -25
- novel_downloader/models/types.py +0 -13
- novel_downloader/tui/__init__.py +0 -7
- novel_downloader/tui/app.py +0 -32
- novel_downloader/tui/main.py +0 -17
- novel_downloader/tui/screens/__init__.py +0 -14
- novel_downloader/tui/screens/home.py +0 -198
- novel_downloader/tui/screens/login.py +0 -74
- novel_downloader/tui/styles/home_layout.tcss +0 -79
- novel_downloader/tui/widgets/richlog_handler.py +0 -24
- novel_downloader/utils/cache.py +0 -24
- novel_downloader/utils/crypto_utils.py +0 -71
- novel_downloader/utils/fontocr/hash_store.py +0 -280
- novel_downloader/utils/fontocr/hash_utils.py +0 -103
- novel_downloader/utils/fontocr/model_loader.py +0 -69
- novel_downloader/utils/fontocr/ocr_v1.py +0 -315
- novel_downloader/utils/fontocr/ocr_v2.py +0 -764
- novel_downloader/utils/fontocr/ocr_v3.py +0 -744
- novel_downloader-1.5.0.dist-info/METADATA +0 -196
- novel_downloader-1.5.0.dist-info/RECORD +0 -164
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/top_level.txt +0 -0
@@ -4,20 +4,18 @@ novel_downloader.utils.epub.utils
|
|
4
4
|
---------------------------------
|
5
5
|
|
6
6
|
Pure utility functions for EPUB assembly, including:
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
* Computing file hashes
|
8
|
+
* Generating META-INF/container.xml
|
9
|
+
* Constructing HTML snippets for the book intro and volume intro
|
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
|
|
@@ -61,48 +59,49 @@ def build_book_intro(
|
|
61
59
|
Build the HTML snippet for the overall book introduction.
|
62
60
|
|
63
61
|
This includes:
|
64
|
-
|
65
|
-
|
66
|
-
|
62
|
+
* A main heading ("Book Introduction")
|
63
|
+
* A list of metadata items (title, author, categories, word count, status)
|
64
|
+
* A "Summary" subheading and one or more paragraphs of summary text
|
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(
|
@@ -113,86 +112,54 @@ def build_volume_intro(
|
|
113
112
|
Build the HTML snippet for a single-volume introduction.
|
114
113
|
|
115
114
|
This includes:
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
115
|
+
* A decorative border image (top and bottom)
|
116
|
+
* A primary heading (volume main title)
|
117
|
+
* An optional secondary line (subtitle)
|
118
|
+
* One or more paragraphs of intro text
|
120
119
|
|
121
120
|
:param volume_title: e.g. "Volume 1 - The Beginning"
|
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,214 +3,79 @@
|
|
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
|
-
import json
|
23
|
-
import logging
|
24
11
|
import tempfile
|
25
12
|
from pathlib import Path
|
26
|
-
from typing import
|
13
|
+
from typing import Literal
|
27
14
|
|
28
15
|
from .sanitize import sanitize_filename
|
29
16
|
|
30
|
-
logger = logging.getLogger(__name__)
|
31
|
-
|
32
|
-
_JSON_INDENT_THRESHOLD = 50 * 1024 # bytes
|
33
|
-
|
34
|
-
|
35
|
-
def _get_non_conflicting_path(path: Path) -> Path:
|
36
|
-
"""
|
37
|
-
If the path exists, generate a new one by appending _1, _2, etc.
|
38
|
-
"""
|
39
|
-
counter = 1
|
40
|
-
new_path = path
|
41
|
-
while new_path.exists():
|
42
|
-
stem = path.stem
|
43
|
-
suffix = path.suffix
|
44
|
-
new_path = path.with_name(f"{stem}_{counter}{suffix}")
|
45
|
-
counter += 1
|
46
|
-
return new_path
|
47
|
-
|
48
|
-
|
49
|
-
def _write_file(
|
50
|
-
content: str | bytes | dict[Any, Any] | list[Any] | Any,
|
51
|
-
filepath: str | Path,
|
52
|
-
write_mode: str = "w",
|
53
|
-
*,
|
54
|
-
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
55
|
-
dump_json: bool = False,
|
56
|
-
encoding: str = "utf-8",
|
57
|
-
) -> Path | None:
|
58
|
-
"""
|
59
|
-
Write content to a file safely with optional atomic behavior
|
60
|
-
and JSON serialization.
|
61
|
-
|
62
|
-
:param content: The content to write; can be text, bytes, or a
|
63
|
-
JSON-serializable object.
|
64
|
-
:param filepath: Destination path (str or Path).
|
65
|
-
:param mode: File mode ('w', 'wb'). Auto-determined if None.
|
66
|
-
:param on_exist: Behavior if file exists: 'overwrite', 'skip',
|
67
|
-
or 'rename'.
|
68
|
-
:param dump_json: If True, serialize content as JSON.
|
69
|
-
:param encoding: Text encoding for writing.
|
70
|
-
:return: Path if writing succeeds, None otherwise.
|
71
|
-
"""
|
72
|
-
path = Path(filepath)
|
73
|
-
path = path.with_name(sanitize_filename(path.name))
|
74
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
75
|
-
|
76
|
-
if path.exists():
|
77
|
-
if on_exist == "skip":
|
78
|
-
logger.debug("[file] '%s' exists, skipping", path)
|
79
|
-
return path
|
80
|
-
if on_exist == "rename":
|
81
|
-
path = _get_non_conflicting_path(path)
|
82
|
-
logger.debug("[file] Renaming target to avoid conflict: %s", path)
|
83
|
-
else:
|
84
|
-
logger.debug("[file] '%s' exists, will overwrite", path)
|
85
|
-
|
86
|
-
# Prepare content and write mode
|
87
|
-
content_to_write: str | bytes
|
88
|
-
if dump_json:
|
89
|
-
# Serialize original object to JSON string
|
90
|
-
json_str = json.dumps(content, ensure_ascii=False, indent=2)
|
91
|
-
if len(json_str.encode(encoding)) > _JSON_INDENT_THRESHOLD:
|
92
|
-
json_str = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
|
93
|
-
content_to_write = json_str
|
94
|
-
write_mode = "w"
|
95
|
-
else:
|
96
|
-
if isinstance(content, (str | bytes)):
|
97
|
-
content_to_write = content
|
98
|
-
else:
|
99
|
-
raise TypeError("Non-JSON content must be str or bytes.")
|
100
|
-
write_mode = "wb" if isinstance(content, bytes) else "w"
|
101
|
-
|
102
|
-
try:
|
103
|
-
with tempfile.NamedTemporaryFile(
|
104
|
-
mode=write_mode,
|
105
|
-
encoding=None if "b" in write_mode else encoding,
|
106
|
-
newline=None if "b" in write_mode else "\n",
|
107
|
-
delete=False,
|
108
|
-
dir=path.parent,
|
109
|
-
) as tmp:
|
110
|
-
tmp.write(content_to_write)
|
111
|
-
tmp_path = Path(tmp.name)
|
112
|
-
tmp_path.replace(path)
|
113
|
-
logger.debug("[file] '%s' written successfully", path)
|
114
|
-
return path
|
115
|
-
except Exception as exc:
|
116
|
-
logger.warning("[file] Error writing %r: %s", path, exc)
|
117
|
-
return None
|
118
|
-
|
119
17
|
|
120
|
-
def
|
121
|
-
content: str,
|
122
|
-
filepath: str | Path,
|
123
|
-
*,
|
124
|
-
encoding: str = "utf-8",
|
125
|
-
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
126
|
-
) -> Path | None:
|
18
|
+
def _unique_path(path: Path, max_tries: int = 100) -> Path:
|
127
19
|
"""
|
128
|
-
|
20
|
+
Return a unique file path by appending _1, _2, ... if needed.
|
129
21
|
|
130
|
-
|
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.
|
22
|
+
Falls back to a UUID suffix if all attempts fail.
|
135
23
|
"""
|
136
|
-
|
137
|
-
|
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.
|
24
|
+
if not path.exists():
|
25
|
+
return path
|
155
26
|
|
156
|
-
|
157
|
-
|
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
|
-
)
|
27
|
+
stem = path.stem
|
28
|
+
suffix = path.suffix
|
170
29
|
|
30
|
+
for counter in range(1, max_tries + 1):
|
31
|
+
candidate = path.with_name(f"{stem}_{counter}{suffix}")
|
32
|
+
if not candidate.exists():
|
33
|
+
return candidate
|
171
34
|
|
172
|
-
|
173
|
-
|
174
|
-
Read a UTF-8 text file.
|
35
|
+
# fallback: append a random/unique suffix
|
36
|
+
import uuid
|
175
37
|
|
176
|
-
|
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
|
38
|
+
return path.with_name(f"{stem}_{uuid.uuid4().hex}{suffix}")
|
186
39
|
|
187
40
|
|
188
|
-
def
|
41
|
+
def write_file(
|
42
|
+
content: str | bytes,
|
43
|
+
filepath: str | Path,
|
44
|
+
*,
|
45
|
+
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
46
|
+
encoding: str = "utf-8",
|
47
|
+
) -> Path:
|
189
48
|
"""
|
190
|
-
|
49
|
+
Write content to a file safely with atomic replacement.
|
191
50
|
|
192
|
-
:param
|
193
|
-
:param
|
194
|
-
:
|
51
|
+
:param content: The content to write; can be text or bytes.
|
52
|
+
:param filepath: Destination path.
|
53
|
+
:param on_exist: Behavior if file exists.
|
54
|
+
:param encoding: Text encoding for writing.
|
55
|
+
:return: The final path where the content was written.
|
56
|
+
:raise: Any I/O error such as PermissionError or OSError
|
195
57
|
"""
|
196
58
|
path = Path(filepath)
|
197
|
-
|
198
|
-
|
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.
|
59
|
+
path = path.with_name(sanitize_filename(path.name))
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
207
61
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
62
|
+
if path.exists():
|
63
|
+
match on_exist:
|
64
|
+
case "skip":
|
65
|
+
return path
|
66
|
+
case "rename":
|
67
|
+
path = _unique_path(path)
|
68
|
+
|
69
|
+
write_mode = "wb" if isinstance(content, bytes) else "w"
|
70
|
+
|
71
|
+
with tempfile.NamedTemporaryFile(
|
72
|
+
mode=write_mode,
|
73
|
+
encoding=None if "b" in write_mode else encoding,
|
74
|
+
newline=None if "b" in write_mode else "\n",
|
75
|
+
delete=False,
|
76
|
+
dir=path.parent,
|
77
|
+
) as tmp:
|
78
|
+
tmp.write(content)
|
79
|
+
tmp_path = Path(tmp.name)
|
80
|
+
tmp_path.replace(path)
|
81
|
+
return path
|
@@ -14,8 +14,6 @@ __all__ = ["normalize_txt_line_endings"]
|
|
14
14
|
import logging
|
15
15
|
from pathlib import Path
|
16
16
|
|
17
|
-
logger = logging.getLogger(__name__)
|
18
|
-
|
19
17
|
|
20
18
|
def normalize_txt_line_endings(folder_path: str | Path) -> None:
|
21
19
|
"""
|
@@ -28,7 +26,6 @@ def normalize_txt_line_endings(folder_path: str | Path) -> None:
|
|
28
26
|
"""
|
29
27
|
path = Path(folder_path).resolve()
|
30
28
|
if not path.exists() or not path.is_dir():
|
31
|
-
logger.warning("[file] Invalid folder: %s", path)
|
32
29
|
return
|
33
30
|
|
34
31
|
count_success, count_fail = 0, 0
|
@@ -38,13 +35,10 @@ def normalize_txt_line_endings(folder_path: str | Path) -> None:
|
|
38
35
|
content = txt_file.read_text(encoding="utf-8")
|
39
36
|
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
40
37
|
txt_file.write_text(normalized, encoding="utf-8", newline="\n")
|
41
|
-
logger.debug("[file] Normalized: %s", txt_file)
|
42
38
|
count_success += 1
|
43
|
-
except (OSError, UnicodeDecodeError)
|
44
|
-
logger.warning("[file] Failed: %s | %s", txt_file, e)
|
39
|
+
except (OSError, UnicodeDecodeError):
|
45
40
|
count_fail += 1
|
46
41
|
|
47
|
-
logger.info("[file] Completed. Success: %s, Failed: %s", count_success, count_fail)
|
48
42
|
return
|
49
43
|
|
50
44
|
|
@@ -5,21 +5,13 @@ 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"]
|
15
11
|
|
16
|
-
import logging
|
17
12
|
import os
|
18
13
|
import re
|
19
14
|
|
20
|
-
logger = logging.getLogger(__name__)
|
21
|
-
|
22
|
-
# Windows 保留名称列表 (忽略大小写)
|
23
15
|
_WIN_RESERVED_NAMES = {
|
24
16
|
"CON",
|
25
17
|
"PRN",
|
@@ -40,8 +32,8 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
|
|
40
32
|
|
41
33
|
This function checks the operating system environment and applies the appropriate
|
42
34
|
filtering rules:
|
43
|
-
|
44
|
-
|
35
|
+
* On Windows, it replaces characters: <>:"/\\|?*
|
36
|
+
* On POSIX systems, it replaces the forward slash '/'
|
45
37
|
|
46
38
|
:param filename: The input filename to sanitize.
|
47
39
|
:param max_length: Optional maximum length of the output filename. Defaults to 255.
|
@@ -51,7 +43,7 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
|
|
51
43
|
|
52
44
|
name = pattern.sub("_", filename).strip(" .")
|
53
45
|
|
54
|
-
stem, dot, ext = name.
|
46
|
+
stem, dot, ext = name.rpartition(".")
|
55
47
|
if os.name == "nt" and stem.upper() in _WIN_RESERVED_NAMES:
|
56
48
|
stem = f"_{stem}"
|
57
49
|
cleaned = f"{stem}{dot}{ext}" if ext else stem
|
@@ -63,7 +55,4 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
|
|
63
55
|
else:
|
64
56
|
cleaned = cleaned[:max_length]
|
65
57
|
|
66
|
-
|
67
|
-
cleaned = "_untitled"
|
68
|
-
logger.debug("[file] Sanitized filename: %r -> %r", filename, cleaned)
|
69
|
-
return cleaned
|
58
|
+
return cleaned or "_untitled"
|
@@ -3,20 +3,11 @@
|
|
3
3
|
novel_downloader.utils.fontocr
|
4
4
|
------------------------------
|
5
5
|
|
6
|
-
|
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`.
|
6
|
+
Lazy-loading interface for FontOCR. Provides a safe entry point
|
7
|
+
to obtain an OCR utility instance if optional dependencies are available.
|
15
8
|
"""
|
16
9
|
|
17
|
-
__all__ = ["
|
18
|
-
__version__ = "
|
10
|
+
__all__ = ["get_font_ocr"]
|
11
|
+
__version__ = "4.0"
|
19
12
|
|
20
|
-
|
21
|
-
# from .ocr_v2 import FontOCRV2 as FontOCR
|
22
|
-
from .ocr_v3 import FontOCRV3 as FontOCR
|
13
|
+
from .loader import get_font_ocr
|