ranobelib-python-sdk 0.4.0__tar.gz → 0.4.1__tar.gz

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 (21) hide show
  1. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/models.py +89 -3
  5. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/README.md +0 -0
  6. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/__init__.py +0 -0
  7. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/cache.py +0 -0
  8. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/client.py +0 -0
  9. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/console.py +0 -0
  10. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exceptions.py +0 -0
  11. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/__init__.py +0 -0
  12. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/_illustrations.py +0 -0
  13. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/_shared.py +0 -0
  14. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/epub.py +0 -0
  15. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/fb2.py +0 -0
  16. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/pdf.py +0 -0
  17. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/exporters/txt.py +0 -0
  18. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/numbering.py +0 -0
  19. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/py.typed +0 -0
  20. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/sdk.py +0 -0
  21. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.4.1}/src/ranobelib/sizing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranobelib-python-sdk
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
5
  Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
6
  Author: LLIJIYAPNIK
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import html
6
6
  from datetime import datetime
7
+ from html.parser import HTMLParser
7
8
  from typing import Any
9
+ from urllib.parse import urlsplit
8
10
 
9
11
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
12
 
@@ -92,6 +94,86 @@ def _prosemirror_to_html(doc: dict[str, Any], attachments: list[dict[str, Any]])
92
94
  return "".join(_render_prosemirror_block(block, attachments) for block in _child_nodes(doc))
93
95
 
94
96
 
97
+ _ALLOWED_CONTENT_TAGS = frozenset({"p", "img", "strong", "em", "br", "hr"})
98
+ _CONTENT_TAG_ALIASES = {"b": "strong", "i": "em"}
99
+ _VOID_CONTENT_TAGS = frozenset({"br", "hr", "img"})
100
+ _DROPPED_CONTENT_TEXT_TAGS = frozenset({"script", "style"})
101
+ _SAFE_IMG_SCHEMES = frozenset({"http", "https"})
102
+
103
+
104
+ class _ContentSanitizer(HTMLParser):
105
+ """Rebuilds an HTML-string-format chapter body using only the same restricted tag
106
+ vocabulary ``_prosemirror_to_html`` already produces for the other content format
107
+ (``p``/``img``/``strong``/``em``/``br``/``hr``) — see ``_normalize_content``.
108
+
109
+ The HTML-string format passes the site's own markup straight from its editor (see
110
+ docs/api-notes.md), so unlike the prosemirror path it isn't SDK-generated and can't be
111
+ trusted as-is: any tag/attribute outside that vocabulary is dropped rather than kept,
112
+ including all attributes on the tags that are kept (``img`` is the one exception, and
113
+ only its ``src`` survives, and only when it resolves to an http(s) URL). ``b``/``i`` are
114
+ folded into ``strong``/``em`` (observed as interchangeable in samples, see
115
+ docs/api-notes.md) rather than dropped, since they're semantically equivalent and not a
116
+ safety concern. Text inside a dropped ``script``/``style`` tag is discarded rather than
117
+ kept as escaped text — anywhere else, a dropped tag's text content is kept (e.g. a
118
+ stripped ``<a href="...">text</a>`` still leaves ``text`` behind).
119
+ """
120
+
121
+ def __init__(self) -> None:
122
+ super().__init__(convert_charrefs=True)
123
+ self._output: list[str] = []
124
+ self._skip_text_depth = 0
125
+
126
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
127
+ self._emit(tag, attrs, self_closing=False)
128
+
129
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
130
+ self._emit(tag, attrs, self_closing=True)
131
+
132
+ def _emit(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
133
+ resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
134
+ if resolved not in _ALLOWED_CONTENT_TAGS:
135
+ if tag in _DROPPED_CONTENT_TEXT_TAGS:
136
+ self._skip_text_depth += 1
137
+ return
138
+ if resolved == "img":
139
+ src = dict(attrs).get("src")
140
+ if src and urlsplit(src).scheme in _SAFE_IMG_SCHEMES:
141
+ self._output.append(f'<img loading="lazy" src="{html.escape(src, quote=True)}" />')
142
+ return
143
+ if resolved in _VOID_CONTENT_TAGS:
144
+ self._output.append(f"<{resolved} />")
145
+ else:
146
+ self._output.append(f"<{resolved}>")
147
+
148
+ def handle_endtag(self, tag: str) -> None:
149
+ resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
150
+ if resolved not in _ALLOWED_CONTENT_TAGS:
151
+ if tag in _DROPPED_CONTENT_TEXT_TAGS and self._skip_text_depth > 0:
152
+ self._skip_text_depth -= 1
153
+ return
154
+ if resolved not in _VOID_CONTENT_TAGS:
155
+ self._output.append(f"</{resolved}>")
156
+
157
+ def handle_data(self, data: str) -> None:
158
+ if self._skip_text_depth == 0:
159
+ self._output.append(html.escape(data))
160
+
161
+ def get_html(self) -> str:
162
+ return "".join(self._output)
163
+
164
+
165
+ def _sanitize_content_html(content: str) -> str:
166
+ """Sanitize an HTML-string-format chapter body down to the restricted tag vocabulary.
167
+
168
+ Counterpart to ``_prosemirror_to_html`` for the other content format the API returns
169
+ (see docs/api-notes.md) — without this, ``Chapter.content`` would only actually be safe
170
+ to render as raw HTML for prosemirror-sourced chapters, not HTML-string-sourced ones.
171
+ """
172
+ sanitizer = _ContentSanitizer()
173
+ sanitizer.feed(content)
174
+ return sanitizer.get_html()
175
+
176
+
95
177
  class Cover(BaseModel):
96
178
  """A set of cover image URLs at different sizes."""
97
179
 
@@ -232,9 +314,13 @@ class Chapter(BaseModel):
232
314
  @model_validator(mode="before")
233
315
  @classmethod
234
316
  def _normalize_content(cls, data: Any) -> Any:
235
- if isinstance(data, dict) and isinstance(data.get("content"), dict):
236
- attachments = data.get("attachments") or []
237
- data = {**data, "content": _prosemirror_to_html(data["content"], attachments)}
317
+ if isinstance(data, dict):
318
+ content = data.get("content")
319
+ if isinstance(content, dict):
320
+ attachments = data.get("attachments") or []
321
+ data = {**data, "content": _prosemirror_to_html(content, attachments)}
322
+ elif isinstance(content, str):
323
+ data = {**data, "content": _sanitize_content_html(content)}
238
324
  return data
239
325
 
240
326