novel-downloader 2.0.0__py3-none-any.whl → 2.0.2__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.
Files changed (137) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +14 -11
  3. novel_downloader/cli/export.py +19 -19
  4. novel_downloader/cli/ui.py +35 -8
  5. novel_downloader/config/adapter.py +216 -153
  6. novel_downloader/core/__init__.py +5 -6
  7. novel_downloader/core/archived/deqixs/fetcher.py +1 -28
  8. novel_downloader/core/downloaders/__init__.py +2 -0
  9. novel_downloader/core/downloaders/base.py +34 -85
  10. novel_downloader/core/downloaders/common.py +147 -171
  11. novel_downloader/core/downloaders/qianbi.py +30 -64
  12. novel_downloader/core/downloaders/qidian.py +157 -184
  13. novel_downloader/core/downloaders/qqbook.py +292 -0
  14. novel_downloader/core/downloaders/registry.py +2 -2
  15. novel_downloader/core/exporters/__init__.py +2 -0
  16. novel_downloader/core/exporters/base.py +37 -59
  17. novel_downloader/core/exporters/common.py +620 -0
  18. novel_downloader/core/exporters/linovelib.py +47 -0
  19. novel_downloader/core/exporters/qidian.py +41 -12
  20. novel_downloader/core/exporters/qqbook.py +28 -0
  21. novel_downloader/core/exporters/registry.py +2 -2
  22. novel_downloader/core/fetchers/__init__.py +4 -2
  23. novel_downloader/core/fetchers/aaatxt.py +2 -22
  24. novel_downloader/core/fetchers/b520.py +3 -23
  25. novel_downloader/core/fetchers/base.py +80 -105
  26. novel_downloader/core/fetchers/biquyuedu.py +2 -22
  27. novel_downloader/core/fetchers/dxmwx.py +10 -22
  28. novel_downloader/core/fetchers/esjzone.py +6 -29
  29. novel_downloader/core/fetchers/guidaye.py +2 -22
  30. novel_downloader/core/fetchers/hetushu.py +9 -29
  31. novel_downloader/core/fetchers/i25zw.py +2 -16
  32. novel_downloader/core/fetchers/ixdzs8.py +2 -16
  33. novel_downloader/core/fetchers/jpxs123.py +2 -16
  34. novel_downloader/core/fetchers/lewenn.py +2 -22
  35. novel_downloader/core/fetchers/linovelib.py +4 -20
  36. novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
  37. novel_downloader/core/fetchers/piaotia.py +2 -16
  38. novel_downloader/core/fetchers/qbtr.py +2 -16
  39. novel_downloader/core/fetchers/qianbi.py +1 -20
  40. novel_downloader/core/fetchers/qidian.py +27 -68
  41. novel_downloader/core/fetchers/qqbook.py +177 -0
  42. novel_downloader/core/fetchers/quanben5.py +9 -29
  43. novel_downloader/core/fetchers/rate_limiter.py +22 -53
  44. novel_downloader/core/fetchers/sfacg.py +3 -16
  45. novel_downloader/core/fetchers/shencou.py +2 -16
  46. novel_downloader/core/fetchers/shuhaige.py +2 -22
  47. novel_downloader/core/fetchers/tongrenquan.py +2 -22
  48. novel_downloader/core/fetchers/ttkan.py +3 -14
  49. novel_downloader/core/fetchers/wanbengo.py +2 -22
  50. novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
  51. novel_downloader/core/fetchers/xiguashuwu.py +4 -20
  52. novel_downloader/core/fetchers/xs63b.py +3 -15
  53. novel_downloader/core/fetchers/xshbook.py +2 -22
  54. novel_downloader/core/fetchers/yamibo.py +4 -28
  55. novel_downloader/core/fetchers/yibige.py +13 -26
  56. novel_downloader/core/interfaces/exporter.py +19 -7
  57. novel_downloader/core/interfaces/fetcher.py +23 -49
  58. novel_downloader/core/interfaces/parser.py +2 -2
  59. novel_downloader/core/parsers/__init__.py +4 -2
  60. novel_downloader/core/parsers/b520.py +2 -2
  61. novel_downloader/core/parsers/base.py +5 -39
  62. novel_downloader/core/parsers/esjzone.py +3 -3
  63. novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +7 -7
  64. novel_downloader/core/parsers/qidian.py +717 -0
  65. novel_downloader/core/parsers/qqbook.py +709 -0
  66. novel_downloader/core/parsers/xiguashuwu.py +8 -15
  67. novel_downloader/core/searchers/__init__.py +2 -2
  68. novel_downloader/core/searchers/b520.py +1 -1
  69. novel_downloader/core/searchers/base.py +2 -2
  70. novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
  71. novel_downloader/locales/en.json +3 -3
  72. novel_downloader/locales/zh.json +3 -3
  73. novel_downloader/models/__init__.py +2 -0
  74. novel_downloader/models/book.py +1 -0
  75. novel_downloader/models/config.py +12 -0
  76. novel_downloader/resources/config/settings.toml +23 -5
  77. novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
  78. novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
  79. novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
  80. novel_downloader/utils/__init__.py +0 -2
  81. novel_downloader/utils/chapter_storage.py +2 -3
  82. novel_downloader/utils/constants.py +7 -3
  83. novel_downloader/utils/cookies.py +32 -17
  84. novel_downloader/utils/crypto_utils/__init__.py +0 -6
  85. novel_downloader/utils/crypto_utils/aes_util.py +1 -1
  86. novel_downloader/utils/crypto_utils/rc4.py +40 -50
  87. novel_downloader/utils/epub/__init__.py +2 -3
  88. novel_downloader/utils/epub/builder.py +6 -6
  89. novel_downloader/utils/epub/constants.py +1 -6
  90. novel_downloader/utils/epub/documents.py +7 -7
  91. novel_downloader/utils/epub/models.py +8 -8
  92. novel_downloader/utils/epub/utils.py +10 -10
  93. novel_downloader/utils/file_utils/io.py +48 -73
  94. novel_downloader/utils/file_utils/normalize.py +1 -7
  95. novel_downloader/utils/file_utils/sanitize.py +4 -11
  96. novel_downloader/utils/fontocr/__init__.py +13 -0
  97. novel_downloader/utils/{fontocr.py → fontocr/core.py} +72 -61
  98. novel_downloader/utils/fontocr/loader.py +52 -0
  99. novel_downloader/utils/logger.py +80 -56
  100. novel_downloader/utils/network.py +16 -40
  101. novel_downloader/utils/node_decryptor/__init__.py +13 -0
  102. novel_downloader/utils/node_decryptor/decryptor.py +342 -0
  103. novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
  104. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  105. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  106. novel_downloader/utils/time_utils/sleep_utils.py +53 -43
  107. novel_downloader/web/main.py +1 -1
  108. novel_downloader/web/pages/download.py +1 -1
  109. novel_downloader/web/pages/search.py +4 -4
  110. novel_downloader/web/services/task_manager.py +2 -0
  111. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +5 -1
  112. novel_downloader-2.0.2.dist-info/RECORD +203 -0
  113. novel_downloader/core/exporters/common/__init__.py +0 -11
  114. novel_downloader/core/exporters/common/epub.py +0 -198
  115. novel_downloader/core/exporters/common/main_exporter.py +0 -64
  116. novel_downloader/core/exporters/common/txt.py +0 -146
  117. novel_downloader/core/exporters/epub_util.py +0 -215
  118. novel_downloader/core/exporters/linovelib/__init__.py +0 -11
  119. novel_downloader/core/exporters/linovelib/epub.py +0 -349
  120. novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
  121. novel_downloader/core/exporters/linovelib/txt.py +0 -139
  122. novel_downloader/core/exporters/txt_util.py +0 -67
  123. novel_downloader/core/parsers/qidian/__init__.py +0 -10
  124. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -89
  125. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -470
  126. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -126
  127. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  128. novel_downloader/core/parsers/qidian/main_parser.py +0 -101
  129. novel_downloader/core/parsers/qidian/utils/__init__.py +0 -30
  130. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +0 -143
  131. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -110
  132. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
  133. novel_downloader-2.0.0.dist-info/RECORD +0 -210
  134. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
  135. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
  136. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
  137. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/top_level.txt +0 -0
@@ -3,62 +3,52 @@
3
3
  novel_downloader.utils.crypto_utils.rc4
4
4
  ---------------------------------------
5
5
 
6
- RC4 stream cipher for simple text encryption and decryption.
6
+ Minimal RC4 stream cipher implementation.
7
7
  """
8
8
 
9
- import base64
10
9
 
11
-
12
- def rc4_crypt(
13
- key: str,
14
- data: str,
15
- *,
16
- mode: str = "encrypt",
17
- encoding: str = "utf-8",
18
- ) -> str:
10
+ def rc4_init(key: bytes) -> list[int]:
19
11
  """
20
- Encrypt or decrypt data using RC4 and Base64.
21
-
22
- :param key: RC4 key (will be encoded using the specified encoding).
23
- :param data: Plain-text (for 'encrypt') or Base64 cipher-text (for 'decrypt').
24
- :param mode: Operation mode, either 'encrypt' or 'decrypt'. Defaults to 'encrypt'.
25
- :param encoding: Character encoding for key and returned string. Defaults 'utf-8'.
26
-
27
- :return: Base64 cipher-text (for encryption) or decoded plain-text (for decryption).
28
-
29
- :raises ValueError: If mode is not 'encrypt' or 'decrypt'.
12
+ Key-Scheduling Algorithm (KSA)
30
13
  """
14
+ S = list(range(256))
15
+ j = 0
16
+ klen = len(key)
17
+ for i in range(256):
18
+ j = (j + S[i] + key[i % klen]) & 0xFF
19
+ S[i], S[j] = S[j], S[i]
20
+ return S
31
21
 
32
- def _rc4(key_bytes: bytes, data_bytes: bytes) -> bytes:
33
- # Key-Scheduling Algorithm (KSA)
34
- S = list(range(256))
35
- j = 0
36
- for i in range(256):
37
- j = (j + S[i] + key_bytes[i % len(key_bytes)]) % 256
38
- S[i], S[j] = S[j], S[i]
39
-
40
- # Pseudo-Random Generation Algorithm (PRGA)
41
- i = j = 0
42
- out: list[int] = []
43
- for char in data_bytes:
44
- i = (i + 1) % 256
45
- j = (j + S[i]) % 256
46
- S[i], S[j] = S[j], S[i]
47
- K = S[(S[i] + S[j]) % 256]
48
- out.append(char ^ K)
49
-
50
- return bytes(out)
51
22
 
52
- key_bytes = key.encode(encoding)
53
-
54
- if mode == "encrypt":
55
- plain_bytes = data.encode(encoding)
56
- cipher_bytes = _rc4(key_bytes, plain_bytes)
57
- return base64.b64encode(cipher_bytes).decode(encoding)
23
+ def rc4_stream(S_init: list[int], data: bytes) -> bytes:
24
+ """
25
+ Pseudo-Random Generation Algorithm (PRGA)
26
+ """
27
+ # make a copy of S since it mutates during PRGA
28
+ S = S_init.copy()
29
+ i = 0
30
+ j = 0
31
+ out = bytearray(len(data))
32
+ for idx, ch in enumerate(data):
33
+ i = (i + 1) & 0xFF
34
+ j = (j + S[i]) & 0xFF
35
+ S[i], S[j] = S[j], S[i]
36
+ K = S[(S[i] + S[j]) & 0xFF]
37
+ out[idx] = ch ^ K
38
+
39
+ return bytes(out)
40
+
41
+
42
+ def rc4_cipher(key: bytes, data: bytes) -> bytes:
43
+ """
44
+ RC4 stream cipher.
58
45
 
59
- if mode == "decrypt":
60
- cipher_bytes = base64.b64decode(data)
61
- plain_bytes = _rc4(key_bytes, cipher_bytes)
62
- return plain_bytes.decode(encoding, errors="replace")
46
+ It performs the standard Key-Scheduling Algorithm (KSA) and
47
+ Pseudo-Random Generation Algorithm (PRGA) to produce the RC4 keystream.
63
48
 
64
- raise ValueError("Mode must be 'encrypt' or 'decrypt'.")
49
+ :param key: RC4 key as bytes (must not be empty)
50
+ :param data: plaintext or ciphertext as bytes
51
+ :return: XORed bytes (encrypt/decrypt are identical)
52
+ """
53
+ S = rc4_init(key)
54
+ return rc4_stream(S, data)
@@ -6,9 +6,8 @@ novel_downloader.utils.epub
6
6
  Top-level package for EPUB export utilities.
7
7
 
8
8
  Key components:
9
-
10
- - EpubBuilder : orchestrates metadata, manifest, spine, navigation, and resources
11
- - Chapter, Volume : represent and render content sections and volume intros
9
+ * EpubBuilder : orchestrates metadata, manifest, spine, navigation, and resources
10
+ * Chapter, Volume : represent and render content sections and volume intros
12
11
 
13
12
  Usage example:
14
13
 
@@ -4,14 +4,14 @@ novel_downloader.utils.epub.builder
4
4
  -----------------------------------
5
5
 
6
6
  Orchestrates the end-to-end EPUB build process by:
7
- - Managing metadata (title, author, description, language, etc.)
8
- - Collecting and deduplicating resources (chapters, images, stylesheets)
9
- - Registering everything in the OPF manifest and spine
10
- - Generating nav.xhtml, toc.ncx, content.opf, and the zipped .epub file
7
+ * Managing metadata (title, author, description, language, etc.)
8
+ * Collecting and deduplicating resources (chapters, images, stylesheets)
9
+ * Registering everything in the OPF manifest and spine
10
+ * Generating nav.xhtml, toc.ncx, content.opf, and the zipped .epub file
11
11
 
12
12
  Provides:
13
- - methods to add chapters, volumes, images, and styles
14
- - a clean `export()` entry point that writes the final EPUB archive
13
+ * methods to add chapters, volumes, images, and styles
14
+ * a clean `export()` entry point that writes the final EPUB archive
15
15
  """
16
16
 
17
17
  import zipfile
@@ -3,12 +3,7 @@
3
3
  novel_downloader.utils.epub.constants
4
4
  -------------------------------------
5
5
 
6
- EPUB-specific constants used by the builder, including:
7
- - Directory names for OEBPS structure
8
- - XML namespace URIs
9
- - Package attributes and document-type declarations
10
- - Media type mappings for images
11
- - Template strings for container.xml and cover image HTML
6
+ EPUB-specific constants used by the builder.
12
7
  """
13
8
 
14
9
  ROOT_PATH = "OEBPS"
@@ -4,9 +4,9 @@ novel_downloader.utils.epub.documents
4
4
  -------------------------------------
5
5
 
6
6
  Defines the classes that render EPUB navigation and packaging documents:
7
- - NavDocument: builds the XHTML nav.xhtml (EPUB 3)
8
- - NCXDocument: builds the NCX XML navigation map (EPUB 2)
9
- - OpfDocument: builds the content.opf package document
7
+ * NavDocument: builds the XHTML nav.xhtml (EPUB 3)
8
+ * NCXDocument: builds the NCX XML navigation map (EPUB 2)
9
+ * OpfDocument: builds the content.opf package document
10
10
  """
11
11
 
12
12
  from collections.abc import Sequence
@@ -234,10 +234,10 @@ class OpfDocument(EpubResource):
234
234
  Generate the content.opf XML, which defines metadata, manifest, and spine.
235
235
 
236
236
  This function outputs a complete OPF package document that includes:
237
- - <metadata>: title, author, language, identifiers, etc.
238
- - <manifest>: all resource entries
239
- - <spine>: the reading order of the content
240
- - <guide>: optional references like cover page
237
+ * <metadata>: title, author, language, identifiers, etc.
238
+ * <manifest>: all resource entries
239
+ * <spine>: the reading order of the content
240
+ * <guide>: optional references like cover page
241
241
 
242
242
  :return: A string containing the full OPF XML content.
243
243
  """
@@ -4,14 +4,14 @@ novel_downloader.utils.epub.models
4
4
  ----------------------------------
5
5
 
6
6
  Defines the core EPUB data models and resource classes used by the builder:
7
- - Typed entries for table of contents (ChapterEntry, VolumeEntry)
8
- - Manifest and spine record types (ManifestEntry, SpineEntry)
9
- - Hierarchical NavPoint for NCX navigation
10
- - Base resource class (EpubResource) and specializations:
11
- - StyleSheet
12
- - ImageResource
13
- - Chapter (with XHTML serialization)
14
- - Volume container for grouping chapters with optional intro and cover
7
+ * Typed entries for table of contents (ChapterEntry, VolumeEntry)
8
+ * Manifest and spine record types (ManifestEntry, SpineEntry)
9
+ * Hierarchical NavPoint for NCX navigation
10
+ * Base resource class (EpubResource) and specializations:
11
+ * StyleSheet
12
+ * ImageResource
13
+ * Chapter (with XHTML serialization)
14
+ * Volume container for grouping chapters with optional intro and cover
15
15
  """
16
16
 
17
17
  from __future__ import annotations
@@ -4,9 +4,9 @@ novel_downloader.utils.epub.utils
4
4
  ---------------------------------
5
5
 
6
6
  Pure utility functions for EPUB assembly, including:
7
- - Computing file hashes
8
- - Generating META-INF/container.xml
9
- - Constructing HTML snippets for the book intro and volume intro
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
@@ -59,9 +59,9 @@ def build_book_intro(
59
59
  Build the HTML snippet for the overall book introduction.
60
60
 
61
61
  This includes:
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
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
65
65
 
66
66
  :return: A HTML string for inclusion in `intro.xhtml`
67
67
  """
@@ -112,10 +112,10 @@ def build_volume_intro(
112
112
  Build the HTML snippet for a single-volume introduction.
113
113
 
114
114
  This includes:
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
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
119
119
 
120
120
  :param volume_title: e.g. "Volume 1 - The Beginning"
121
121
  :param volume_intro_text: multiline intro text for this volume
@@ -8,99 +8,74 @@ File I/O utilities for reading and writing data.
8
8
 
9
9
  __all__ = ["write_file"]
10
10
 
11
- import json
12
- import logging
13
11
  import tempfile
14
12
  from pathlib import Path
15
- from typing import Any, Literal
13
+ from typing import Literal
16
14
 
17
15
  from .sanitize import sanitize_filename
18
16
 
19
- logger = logging.getLogger(__name__)
20
17
 
21
- _JSON_INDENT_THRESHOLD = 50 * 1024 # bytes
22
-
23
-
24
- def _get_non_conflicting_path(path: Path) -> Path:
18
+ def _unique_path(path: Path, max_tries: int = 100) -> Path:
25
19
  """
26
- If the path exists, generate a new one by appending _1, _2, etc.
20
+ Return a unique file path by appending _1, _2, ... if needed.
21
+
22
+ Falls back to a UUID suffix if all attempts fail.
27
23
  """
28
- counter = 1
29
- new_path = path
30
- while new_path.exists():
31
- stem = path.stem
32
- suffix = path.suffix
33
- new_path = path.with_name(f"{stem}_{counter}{suffix}")
34
- counter += 1
35
- return new_path
24
+ if not path.exists():
25
+ return path
26
+
27
+ stem = path.stem
28
+ suffix = path.suffix
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
34
+
35
+ # fallback: append a random/unique suffix
36
+ import uuid
37
+
38
+ return path.with_name(f"{stem}_{uuid.uuid4().hex}{suffix}")
36
39
 
37
40
 
38
41
  def write_file(
39
- content: str | bytes | dict[Any, Any] | list[Any] | Any,
42
+ content: str | bytes,
40
43
  filepath: str | Path,
41
- write_mode: str = "w",
42
44
  *,
43
45
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
44
- dump_json: bool = False,
45
46
  encoding: str = "utf-8",
46
- ) -> Path | None:
47
+ ) -> Path:
47
48
  """
48
- Write content to a file safely with optional atomic behavior
49
- and JSON serialization.
50
-
51
- :param content: The content to write; can be text, bytes, or a
52
- JSON-serializable object.
53
- :param filepath: Destination path (str or Path).
54
- :param mode: File mode ('w', 'wb'). Auto-determined if None.
55
- :param on_exist: Behavior if file exists: 'overwrite', 'skip',
56
- or 'rename'.
57
- :param dump_json: If True, serialize content as JSON.
49
+ Write content to a file safely with atomic replacement.
50
+
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.
58
54
  :param encoding: Text encoding for writing.
59
- :return: Path if writing succeeds, None otherwise.
55
+ :return: The final path where the content was written.
56
+ :raise: Any I/O error such as PermissionError or OSError
60
57
  """
61
58
  path = Path(filepath)
62
59
  path = path.with_name(sanitize_filename(path.name))
63
60
  path.parent.mkdir(parents=True, exist_ok=True)
64
61
 
65
62
  if path.exists():
66
- if on_exist == "skip":
67
- logger.debug("[file] '%s' exists, skipping", path)
68
- return path
69
- if on_exist == "rename":
70
- path = _get_non_conflicting_path(path)
71
- logger.debug("[file] Renaming target to avoid conflict: %s", path)
72
- else:
73
- logger.debug("[file] '%s' exists, will overwrite", path)
74
-
75
- # Prepare content and write mode
76
- content_to_write: str | bytes
77
- if dump_json:
78
- # Serialize original object to JSON string
79
- json_str = json.dumps(content, ensure_ascii=False, indent=2)
80
- if len(json_str.encode(encoding)) > _JSON_INDENT_THRESHOLD:
81
- json_str = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
82
- content_to_write = json_str
83
- write_mode = "w"
84
- else:
85
- if isinstance(content, (str | bytes)):
86
- content_to_write = content
87
- else:
88
- raise TypeError("Non-JSON content must be str or bytes.")
89
- write_mode = "wb" if isinstance(content, bytes) else "w"
90
-
91
- try:
92
- with tempfile.NamedTemporaryFile(
93
- mode=write_mode,
94
- encoding=None if "b" in write_mode else encoding,
95
- newline=None if "b" in write_mode else "\n",
96
- delete=False,
97
- dir=path.parent,
98
- ) as tmp:
99
- tmp.write(content_to_write)
100
- tmp_path = Path(tmp.name)
101
- tmp_path.replace(path)
102
- logger.debug("[file] '%s' written successfully", path)
103
- return path
104
- except Exception as exc:
105
- logger.warning("[file] Error writing %r: %s", path, exc)
106
- return None
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) as e:
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
 
@@ -9,13 +9,9 @@ on different operating systems.
9
9
 
10
10
  __all__ = ["sanitize_filename"]
11
11
 
12
- import logging
13
12
  import os
14
13
  import re
15
14
 
16
- logger = logging.getLogger(__name__)
17
-
18
- # Windows 保留名称列表 (忽略大小写)
19
15
  _WIN_RESERVED_NAMES = {
20
16
  "CON",
21
17
  "PRN",
@@ -36,8 +32,8 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
36
32
 
37
33
  This function checks the operating system environment and applies the appropriate
38
34
  filtering rules:
39
- - On Windows, it replaces characters: <>:"/\\|?*
40
- - On POSIX systems, it replaces the forward slash '/'
35
+ * On Windows, it replaces characters: <>:"/\\|?*
36
+ * On POSIX systems, it replaces the forward slash '/'
41
37
 
42
38
  :param filename: The input filename to sanitize.
43
39
  :param max_length: Optional maximum length of the output filename. Defaults to 255.
@@ -47,7 +43,7 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
47
43
 
48
44
  name = pattern.sub("_", filename).strip(" .")
49
45
 
50
- stem, dot, ext = name.partition(".")
46
+ stem, dot, ext = name.rpartition(".")
51
47
  if os.name == "nt" and stem.upper() in _WIN_RESERVED_NAMES:
52
48
  stem = f"_{stem}"
53
49
  cleaned = f"{stem}{dot}{ext}" if ext else stem
@@ -59,7 +55,4 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
59
55
  else:
60
56
  cleaned = cleaned[:max_length]
61
57
 
62
- if not cleaned:
63
- cleaned = "_untitled"
64
- logger.debug("[file] Sanitized filename: %r -> %r", filename, cleaned)
65
- return cleaned
58
+ return cleaned or "_untitled"
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.fontocr
4
+ ------------------------------
5
+
6
+ Lazy-loading interface for FontOCR. Provides a safe entry point
7
+ to obtain an OCR utility instance if optional dependencies are available.
8
+ """
9
+
10
+ __all__ = ["get_font_ocr"]
11
+ __version__ = "4.0"
12
+
13
+ from .loader import get_font_ocr