twi-scraper 0.1.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.
- twi/__init__.py +3 -0
- twi/clean.py +599 -0
- twi/cli.py +424 -0
- twi/epub.py +182 -0
- twi/markdown.py +144 -0
- twi/model.py +143 -0
- twi/site.py +337 -0
- twi/unistyle.py +131 -0
- twi_scraper-0.1.0.dist-info/METADATA +155 -0
- twi_scraper-0.1.0.dist-info/RECORD +13 -0
- twi_scraper-0.1.0.dist-info/WHEEL +4 -0
- twi_scraper-0.1.0.dist-info/entry_points.txt +2 -0
- twi_scraper-0.1.0.dist-info/licenses/LICENSE +21 -0
twi/__init__.py
ADDED
twi/clean.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""Turn a chapter's HTML into clean blocks.
|
|
2
|
+
|
|
3
|
+
Removes navigation links, author's notes, fan art, galleries and leading
|
|
4
|
+
announcements; turns the author's dash separators into proper rules.
|
|
5
|
+
See CLAUDE.md for the site markup these rules are based on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field, replace
|
|
12
|
+
|
|
13
|
+
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
|
|
14
|
+
|
|
15
|
+
from .model import Break, Em, Heading, Link, Para, Redaction, Rule, Strong, Text, norm_url, plain_text
|
|
16
|
+
from .unistyle import normalize as normalize_styled
|
|
17
|
+
|
|
18
|
+
# Tags whose content never belongs in the text.
|
|
19
|
+
DROP_TAGS = {
|
|
20
|
+
"script", "style", "noscript", "iframe", "img", "picture", "svg", "figure",
|
|
21
|
+
"meta", "link", "form", "input", "button", "video", "audio", "source",
|
|
22
|
+
"canvas", "object", "embed", "aside", "template", "select", "textarea",
|
|
23
|
+
}
|
|
24
|
+
# Classes of elements to drop wholesale (galleries, embeds).
|
|
25
|
+
DROP_CLASSES = re.compile(
|
|
26
|
+
r"tiled-gallery|wp-block-gallery|\bgallery\b|twitter-tweet|wp-block-embed|"
|
|
27
|
+
r"sharedaddy|jp-relatedposts|wpcnt|patreon-protected-post"
|
|
28
|
+
)
|
|
29
|
+
BLOCK_TAGS = {
|
|
30
|
+
"p", "div", "section", "article", "main", "header", "footer", "blockquote",
|
|
31
|
+
"ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "td",
|
|
32
|
+
"th", "center", "pre", "address", "h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
|
33
|
+
"details", "summary", "nav",
|
|
34
|
+
}
|
|
35
|
+
LEAF_BLOCKS = {"p", "li", "dt", "dd", "pre", "address", "h1", "h2", "h3", "h4", "h5", "h6", "summary"}
|
|
36
|
+
EM_TAGS = {"em", "i", "cite", "var", "dfn"}
|
|
37
|
+
STRONG_TAGS = {"strong", "b"}
|
|
38
|
+
EM_CLASSES = {"mrsha-write"}
|
|
39
|
+
|
|
40
|
+
WS = re.compile(r"[\s ]+")
|
|
41
|
+
SEPARATOR = re.compile(r"^[\-‐-―−_*~=+#•·◦⁂∗\s]+$")
|
|
42
|
+
NAV_WORDS = re.compile(r"(previous|next)\s+chapter|table\s+of\s+contents|\bindex\b", re.I)
|
|
43
|
+
# "Author's Note:" and the older "After Chapter Thoughts:" label (Volume 7 era).
|
|
44
|
+
NOTE_LABEL = r"\W{0,3}(?:author[’'`]?s?\s*['’]?\s*notes?|after[\s-]*chapter\s+thoughts)"
|
|
45
|
+
AUTHOR_NOTE = re.compile(rf"^{NOTE_LABEL}", re.I)
|
|
46
|
+
CHAPTER_MARK = re.compile(r"^chapter\s+(\d+|[ivxlc]+)$", re.I)
|
|
47
|
+
URLISH = re.compile(r"^(https?://|www\.)\S+$", re.I)
|
|
48
|
+
CW_WORDS = re.compile(r"trigger[\s-]*warning|content\s+warning", re.I)
|
|
49
|
+
PASSWORD_HINT = re.compile(r"password\s+is\s+(?:also\s+)?[‘'\"“]?([^\s‘’'\"“”]+?)[’'\"”]?(?=[\s.,;)\]}>]|$)", re.I)
|
|
50
|
+
PAIRS = {"(": ")", "[": "]", "{": "}", "<": ">"}
|
|
51
|
+
ANNOUNCE_WORDS = re.compile(
|
|
52
|
+
r"\b(patreon|patrons?|author|audiobook|ebook|pre-?order|release[sd]?|update|"
|
|
53
|
+
r"on break|public readers|chapter|warning|password|discord|webcomic)\b", re.I)
|
|
54
|
+
|
|
55
|
+
CONTENT_WARNING_TEXT = "Content warning: this chapter has a trigger warning for disturbing content."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CleanResult:
|
|
60
|
+
blocks: list
|
|
61
|
+
log: list[str] = field(default_factory=list)
|
|
62
|
+
content_warning: bool = False
|
|
63
|
+
# kind ("trigger-free" / "summary") -> (url, password or None)
|
|
64
|
+
alternates: dict[str, tuple[str, str | None]] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- HTML -> raw blocks -----------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _classes(tag: Tag) -> str:
|
|
71
|
+
return " ".join(tag.get("class") or [])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_SEP = r"[\s,.!?…;:]"
|
|
75
|
+
_RWORD = r"[\w'’\-]*█[█\w'’\-]*"
|
|
76
|
+
REDACTED_GROUP_AT_END = re.compile(rf"({_RWORD}(?:{_SEP}{{1,3}}{_RWORD})*)({_SEP}{{0,3}})$")
|
|
77
|
+
REDACTED_RUN = re.compile(rf"{_RWORD}(?:{_SEP}{{1,3}}{_RWORD})*")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fill_redaction(group: str, hidden: str) -> str:
|
|
81
|
+
"""Rebuild the original words: 7.25 hides exactly one letter per █.
|
|
82
|
+
|
|
83
|
+
"███, ███" + "one, two" -> "one, two"; "█i█g█" + "wns" -> "wings".
|
|
84
|
+
Falls back to the comment text if the counts don't line up.
|
|
85
|
+
"""
|
|
86
|
+
letters = [c for c in hidden if c.isalnum()]
|
|
87
|
+
if group.count("█") != len(letters):
|
|
88
|
+
return hidden
|
|
89
|
+
it = iter(letters)
|
|
90
|
+
return "".join(next(it) if c == "█" else c for c in group)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _mark_redactions(root: Tag) -> None:
|
|
94
|
+
"""Pair "███<!--hidden words-->" so the comment isn't lost when comments go."""
|
|
95
|
+
for comment in list(root.find_all(string=lambda s: isinstance(s, Comment))):
|
|
96
|
+
prev = comment.previous_sibling
|
|
97
|
+
if not isinstance(prev, NavigableString) or isinstance(prev, Comment):
|
|
98
|
+
continue
|
|
99
|
+
m = REDACTED_GROUP_AT_END.search(str(prev))
|
|
100
|
+
hidden = WS.sub(" ", str(comment)).strip()
|
|
101
|
+
if not m or not hidden:
|
|
102
|
+
continue
|
|
103
|
+
tag = BeautifulSoup("", "lxml").new_tag("twi-redact")
|
|
104
|
+
tag["data-reveal"] = _fill_redaction(m.group(1), hidden)
|
|
105
|
+
tag.string = m.group(1)
|
|
106
|
+
before = NavigableString(str(prev)[: m.start()])
|
|
107
|
+
prev.replace_with(before)
|
|
108
|
+
before.insert_after(tag)
|
|
109
|
+
if m.group(2):
|
|
110
|
+
tag.insert_after(NavigableString(m.group(2)))
|
|
111
|
+
comment.extract()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _plain(text: str) -> list:
|
|
115
|
+
"""Text nodes with look-alike "font" characters turned into plain letters."""
|
|
116
|
+
return [Text(t, style) for t, style in normalize_styled(text)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _text_nodes(text: str) -> list:
|
|
120
|
+
"""Split text into Text and (comment-less) Redaction nodes."""
|
|
121
|
+
if "█" not in text:
|
|
122
|
+
return _plain(text)
|
|
123
|
+
out: list = []
|
|
124
|
+
pos = 0
|
|
125
|
+
for m in REDACTED_RUN.finditer(text):
|
|
126
|
+
if m.start() > pos:
|
|
127
|
+
out.extend(_plain(text[pos:m.start()]))
|
|
128
|
+
out.append(Redaction(m.group(0)))
|
|
129
|
+
pos = m.end()
|
|
130
|
+
if pos < len(text):
|
|
131
|
+
out.extend(_plain(text[pos:]))
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _strip_junk(root: Tag) -> None:
|
|
136
|
+
_mark_redactions(root)
|
|
137
|
+
for node in root.find_all(string=lambda s: isinstance(s, Comment)):
|
|
138
|
+
node.extract()
|
|
139
|
+
for tag in root.find_all(True):
|
|
140
|
+
if tag.decomposed:
|
|
141
|
+
continue
|
|
142
|
+
if tag.name in DROP_TAGS or DROP_CLASSES.search(_classes(tag)):
|
|
143
|
+
tag.decompose()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _inlines(node: Tag) -> list:
|
|
147
|
+
out: list = []
|
|
148
|
+
for child in node.children:
|
|
149
|
+
if isinstance(child, Comment):
|
|
150
|
+
continue
|
|
151
|
+
if isinstance(child, NavigableString):
|
|
152
|
+
text = WS.sub(" ", str(child))
|
|
153
|
+
if text:
|
|
154
|
+
out.extend(_text_nodes(text))
|
|
155
|
+
continue
|
|
156
|
+
if not isinstance(child, Tag):
|
|
157
|
+
continue
|
|
158
|
+
name = child.name
|
|
159
|
+
if name == "br":
|
|
160
|
+
out.append(Break())
|
|
161
|
+
elif name == "twi-redact":
|
|
162
|
+
out.append(Redaction(child.get_text(), child.get("data-reveal") or None))
|
|
163
|
+
elif name in EM_TAGS or (name == "span" and EM_CLASSES & set(child.get("class") or [])):
|
|
164
|
+
kids = _inlines(child)
|
|
165
|
+
if kids:
|
|
166
|
+
out.append(Em(kids))
|
|
167
|
+
elif name in STRONG_TAGS:
|
|
168
|
+
kids = _inlines(child)
|
|
169
|
+
if kids:
|
|
170
|
+
out.append(Strong(kids))
|
|
171
|
+
elif name == "a":
|
|
172
|
+
kids = _inlines(child)
|
|
173
|
+
href = (child.get("href") or "").strip()
|
|
174
|
+
if not plain_text(kids).strip():
|
|
175
|
+
continue # image links, empty anchors
|
|
176
|
+
if href and not re.search(r"\.(jpe?g|png|gif|webp)(\?.*)?$", href, re.I):
|
|
177
|
+
out.append(Link(href, kids))
|
|
178
|
+
else:
|
|
179
|
+
out.extend(kids)
|
|
180
|
+
else: # span, u, sup, font, block tags nested in inline context...
|
|
181
|
+
out.extend(_inlines(child))
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _has_block_child(tag: Tag) -> bool:
|
|
186
|
+
return any(isinstance(c, Tag) and c.name in BLOCK_TAGS for c in tag.descendants)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _collect(node: Tag, out: list) -> None:
|
|
190
|
+
pending: list = [] # loose inline content at block level
|
|
191
|
+
|
|
192
|
+
def flush():
|
|
193
|
+
if pending:
|
|
194
|
+
frag = BeautifulSoup("", "lxml").new_tag("p")
|
|
195
|
+
for item in pending:
|
|
196
|
+
frag.append(item)
|
|
197
|
+
out.append(Para(_inlines(frag)))
|
|
198
|
+
pending.clear()
|
|
199
|
+
|
|
200
|
+
for child in list(node.children):
|
|
201
|
+
if isinstance(child, Comment):
|
|
202
|
+
continue
|
|
203
|
+
if isinstance(child, NavigableString):
|
|
204
|
+
if str(child).strip():
|
|
205
|
+
pending.append(child.extract())
|
|
206
|
+
continue
|
|
207
|
+
if not isinstance(child, Tag):
|
|
208
|
+
continue
|
|
209
|
+
if child.name == "hr":
|
|
210
|
+
flush()
|
|
211
|
+
out.append(Rule())
|
|
212
|
+
elif child.name in LEAF_BLOCKS:
|
|
213
|
+
flush()
|
|
214
|
+
if _has_block_child(child) and child.name not in {"pre"}:
|
|
215
|
+
_collect(child, out)
|
|
216
|
+
else:
|
|
217
|
+
out.append(Para(_inlines(child)))
|
|
218
|
+
elif child.name in BLOCK_TAGS:
|
|
219
|
+
flush()
|
|
220
|
+
if _has_block_child(child):
|
|
221
|
+
_collect(child, out)
|
|
222
|
+
else:
|
|
223
|
+
out.append(Para(_inlines(child)))
|
|
224
|
+
elif child.name == "br":
|
|
225
|
+
continue
|
|
226
|
+
else:
|
|
227
|
+
pending.append(child.extract())
|
|
228
|
+
flush()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def html_to_blocks(html: str) -> list:
|
|
232
|
+
soup = BeautifulSoup(html, "lxml")
|
|
233
|
+
root = soup.find("article", class_="twi-article") or soup.body or soup
|
|
234
|
+
_strip_junk(root)
|
|
235
|
+
blocks: list = []
|
|
236
|
+
_collect(root, blocks)
|
|
237
|
+
return blocks
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- classification helpers -------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _text(block) -> str:
|
|
244
|
+
if isinstance(block, Para):
|
|
245
|
+
return WS.sub(" ", plain_text(block.inlines, raw_redactions=True)).strip()
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_blank(block) -> bool:
|
|
250
|
+
return isinstance(block, Para) and not _text(block)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_separator(block) -> bool:
|
|
254
|
+
if isinstance(block, Rule):
|
|
255
|
+
return True
|
|
256
|
+
t = _text(block)
|
|
257
|
+
return bool(t) and bool(SEPARATOR.match(t)) and len(t.replace(" ", "")) >= 2
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _is_nav(block) -> bool:
|
|
261
|
+
t = _text(block)
|
|
262
|
+
if not t or not NAV_WORDS.search(t):
|
|
263
|
+
return False
|
|
264
|
+
return not NAV_WORDS.sub("", t).strip(" |·•-–—")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _links(inlines: list) -> list[Link]:
|
|
268
|
+
out = []
|
|
269
|
+
for n in inlines:
|
|
270
|
+
if isinstance(n, Link):
|
|
271
|
+
out.append(n)
|
|
272
|
+
elif isinstance(n, (Em, Strong)):
|
|
273
|
+
out.extend(_links(n.children))
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _all_bold(inlines: list) -> bool:
|
|
278
|
+
"""True if every non-blank text run sits inside <strong>."""
|
|
279
|
+
def walk(nodes, bold):
|
|
280
|
+
for n in nodes:
|
|
281
|
+
if isinstance(n, Text):
|
|
282
|
+
if n.text.strip() and not bold:
|
|
283
|
+
return False
|
|
284
|
+
elif isinstance(n, Strong):
|
|
285
|
+
if not walk(n.children, True):
|
|
286
|
+
return False
|
|
287
|
+
elif isinstance(n, (Em, Link)):
|
|
288
|
+
if not walk(n.children, bold):
|
|
289
|
+
return False
|
|
290
|
+
return True
|
|
291
|
+
return walk(inlines, False)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
CHAPTER_URL = re.compile(r"^https://wanderinginn\.com/\d{4}/\d{2}/\d{2}/")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _is_link_only(block) -> bool:
|
|
298
|
+
"""A promo paragraph: a bare URL, or only links pointing away from the story.
|
|
299
|
+
|
|
300
|
+
Links to other chapters are story text (Solstice's "—Saw." links to the next
|
|
301
|
+
part), so they never count.
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(block, Para):
|
|
304
|
+
return False
|
|
305
|
+
t = _text(block)
|
|
306
|
+
if not t:
|
|
307
|
+
return False
|
|
308
|
+
if URLISH.match(t):
|
|
309
|
+
return True
|
|
310
|
+
links = _links(block.inlines)
|
|
311
|
+
if not links or any(CHAPTER_URL.match(norm_url(l.href) or "") for l in links):
|
|
312
|
+
return False
|
|
313
|
+
rest = WS.sub(" ", t)
|
|
314
|
+
for link in links:
|
|
315
|
+
rest = rest.replace(WS.sub(" ", plain_text(link.children, raw_redactions=True)).strip(), "", 1)
|
|
316
|
+
rest = rest.strip()
|
|
317
|
+
# Nothing but links, or a short label like "Android:" / "Ko-Fi:" before them.
|
|
318
|
+
return not rest or (len(rest) <= 30 and rest.endswith(":"))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _wrapped_group(blocks: list, i: int) -> int:
|
|
322
|
+
"""If blocks[i] opens a bracketed announcement, return its length (else 0)."""
|
|
323
|
+
t = _text(blocks[i])
|
|
324
|
+
if not t or t[0] not in PAIRS:
|
|
325
|
+
return 0
|
|
326
|
+
closer = PAIRS[t[0]]
|
|
327
|
+
for j in range(i, min(i + 6, len(blocks))):
|
|
328
|
+
tj = _text(blocks[j]).rstrip(" .")
|
|
329
|
+
if not tj:
|
|
330
|
+
return 0
|
|
331
|
+
if tj.endswith(closer):
|
|
332
|
+
n = j - i + 1
|
|
333
|
+
if t[0] != "(":
|
|
334
|
+
# "[Skill]" and "<Query>" lines are story text; require announcement traits.
|
|
335
|
+
group = blocks[i:j + 1]
|
|
336
|
+
joined = " ".join(_text(b) for b in group)
|
|
337
|
+
long = len(joined) >= 150
|
|
338
|
+
linked = any(_links(b.inlines) for b in group)
|
|
339
|
+
bold = all(_all_bold(b.inlines) for b in group)
|
|
340
|
+
worded = bool(ANNOUNCE_WORDS.search(joined))
|
|
341
|
+
if not (n > 1 or long or linked or bold or worded):
|
|
342
|
+
return 0
|
|
343
|
+
return n
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# --- cleaning passes --------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _remove_nav(blocks: list) -> list:
|
|
351
|
+
out = [b for b in blocks if not _is_nav(b)]
|
|
352
|
+
while out and (isinstance(out[-1], Rule) or _is_blank(out[-1])):
|
|
353
|
+
out.pop()
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _note_end(blocks: list, i: int) -> int:
|
|
358
|
+
"""End index of a note starting at i: up to the next blank line or separator."""
|
|
359
|
+
j = i + 1
|
|
360
|
+
while j < len(blocks) and not _is_blank(blocks[j]) and not _is_separator(blocks[j]):
|
|
361
|
+
j += 1
|
|
362
|
+
return j
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _leading_end(blocks: list) -> int:
|
|
366
|
+
"""Index of the first story paragraph after any opening announcements."""
|
|
367
|
+
i = 0
|
|
368
|
+
while i < len(blocks):
|
|
369
|
+
b = blocks[i]
|
|
370
|
+
if _is_blank(b) or isinstance(b, Rule):
|
|
371
|
+
i += 1
|
|
372
|
+
elif AUTHOR_NOTE.match(_text(b)):
|
|
373
|
+
i = _note_end(blocks, i)
|
|
374
|
+
elif n := _wrapped_group(blocks, i):
|
|
375
|
+
i += n
|
|
376
|
+
elif _is_link_only(b):
|
|
377
|
+
i += 1
|
|
378
|
+
else:
|
|
379
|
+
break
|
|
380
|
+
return i
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _strip_leading(blocks: list, res: CleanResult) -> list:
|
|
384
|
+
i = _leading_end(blocks)
|
|
385
|
+
removed = [b for b in blocks[:i] if not _is_blank(b) and not isinstance(b, Rule)]
|
|
386
|
+
if removed:
|
|
387
|
+
text = " ".join(_text(b) for b in removed)
|
|
388
|
+
res.log.append(f"removed opening announcement: {text[:160]}{'…' if len(text) > 160 else ''}")
|
|
389
|
+
if CW_WORDS.search(text):
|
|
390
|
+
res.content_warning = True
|
|
391
|
+
_find_alternates(removed, res)
|
|
392
|
+
return blocks[i:]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _find_alternates(removed: list, res: CleanResult) -> None:
|
|
396
|
+
for b in removed:
|
|
397
|
+
if not isinstance(b, Para):
|
|
398
|
+
continue
|
|
399
|
+
t = _text(b)
|
|
400
|
+
pw = PASSWORD_HINT.search(t)
|
|
401
|
+
for link in _links(b.inlines):
|
|
402
|
+
url = norm_url(link.href)
|
|
403
|
+
if not url:
|
|
404
|
+
continue
|
|
405
|
+
ctx = (t + " " + url).lower()
|
|
406
|
+
if "trigger-free" in ctx or "trigger free" in ctx:
|
|
407
|
+
kind = "trigger-free"
|
|
408
|
+
elif "summary" in url:
|
|
409
|
+
kind = "summary"
|
|
410
|
+
else:
|
|
411
|
+
continue
|
|
412
|
+
res.alternates.setdefault(kind, (url, pw.group(1) if pw else None))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _closing_group_start(blocks: list, end: int) -> int | None:
|
|
416
|
+
"""Start of a bold or linked bracketed group ending just before `end`."""
|
|
417
|
+
for start in range(end - 1, max(-1, end - 7), -1):
|
|
418
|
+
if _wrapped_group(blocks, start) == end - start:
|
|
419
|
+
group = blocks[start:end]
|
|
420
|
+
if any(_links(g.inlines) for g in group) or all(_all_bold(g.inlines) for g in group):
|
|
421
|
+
return start
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _strip_trailing(blocks: list, res: CleanResult) -> list:
|
|
426
|
+
"""Remove closing announcements, e.g. Solstice's "(Before going to next chapter…)"."""
|
|
427
|
+
end = len(blocks)
|
|
428
|
+
removed: list = []
|
|
429
|
+
while end > 0:
|
|
430
|
+
b = blocks[end - 1]
|
|
431
|
+
if _is_blank(b) or isinstance(b, Rule):
|
|
432
|
+
end -= 1
|
|
433
|
+
continue
|
|
434
|
+
if _is_link_only(b):
|
|
435
|
+
removed.insert(0, b)
|
|
436
|
+
end -= 1
|
|
437
|
+
continue
|
|
438
|
+
start = _closing_group_start(blocks, end)
|
|
439
|
+
if start is None:
|
|
440
|
+
break
|
|
441
|
+
removed[:0] = blocks[start:end]
|
|
442
|
+
end = start
|
|
443
|
+
if removed:
|
|
444
|
+
text = " ".join(_text(b) for b in removed)
|
|
445
|
+
res.log.append(f"removed closing announcement: {text[:160]}{'…' if len(text) > 160 else ''}")
|
|
446
|
+
return blocks[:end]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
CREDIT_URL = re.compile(r"https?://|www\.|\b[\w-]+\.(com|art|net|org|app|social|se)/", re.I)
|
|
450
|
+
CREDIT_BY = re.compile(r"\bby\b", re.I)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _is_credit(block) -> bool:
|
|
454
|
+
"""A fan-art credit line: "Erin by Brack!" or "Ko-Fi: https://ko-fi.com/brack"."""
|
|
455
|
+
if not isinstance(block, Para):
|
|
456
|
+
return False
|
|
457
|
+
t = _text(block)
|
|
458
|
+
if not t:
|
|
459
|
+
return True
|
|
460
|
+
if CREDIT_URL.search(t) and len(t) < 200:
|
|
461
|
+
return True
|
|
462
|
+
return bool(CREDIT_BY.search(t)) and len(t) < 140 and t.count(". ") <= 1
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _strip_credits(blocks: list, res: CleanResult, after: int | None) -> list:
|
|
466
|
+
"""Cut the trailing run of art-credit lines (the gallery images are already gone).
|
|
467
|
+
|
|
468
|
+
`after`: index where the author's note starts, or None if there is none, in
|
|
469
|
+
which case the run must contain a URL so a story's last line is never cut.
|
|
470
|
+
"""
|
|
471
|
+
start = len(blocks)
|
|
472
|
+
floor = (after + 1) if after is not None else 0
|
|
473
|
+
while start > floor and (_is_credit(blocks[start - 1]) or isinstance(blocks[start - 1], Rule)):
|
|
474
|
+
start -= 1
|
|
475
|
+
run = blocks[start:]
|
|
476
|
+
if not any(_text(b) for b in run):
|
|
477
|
+
return blocks
|
|
478
|
+
if after is None and not any(CREDIT_URL.search(_text(b)) for b in run):
|
|
479
|
+
return blocks
|
|
480
|
+
res.log.append(f"removed {sum(1 for b in run if _text(b))} fan-art credit lines")
|
|
481
|
+
return blocks[:start]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _find_author_note(blocks: list) -> int | None:
|
|
485
|
+
for i, b in enumerate(blocks):
|
|
486
|
+
if AUTHOR_NOTE.match(_text(b)) and i >= len(blocks) * 0.5:
|
|
487
|
+
return i
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _drop_chars(inlines: list, n: int) -> list:
|
|
492
|
+
"""Remove the first n characters of text from an inline tree."""
|
|
493
|
+
out = []
|
|
494
|
+
for node in inlines:
|
|
495
|
+
if n <= 0:
|
|
496
|
+
out.append(node)
|
|
497
|
+
elif isinstance(node, Text):
|
|
498
|
+
if len(node.text) <= n:
|
|
499
|
+
n -= len(node.text)
|
|
500
|
+
else:
|
|
501
|
+
out.append(replace(node, text=node.text[n:]))
|
|
502
|
+
n = 0
|
|
503
|
+
elif isinstance(node, Break):
|
|
504
|
+
n -= 1
|
|
505
|
+
elif isinstance(node, Redaction):
|
|
506
|
+
out.append(node)
|
|
507
|
+
n = 0
|
|
508
|
+
else:
|
|
509
|
+
size = len(plain_text(node.children))
|
|
510
|
+
if size <= n:
|
|
511
|
+
n -= size
|
|
512
|
+
else:
|
|
513
|
+
out.append(type(node)(*([node.href] if isinstance(node, Link) else []), _drop_chars(node.children, n)))
|
|
514
|
+
n = 0
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _mark_author_note(blocks: list, res: CleanResult) -> list:
|
|
519
|
+
"""Keep-notes mode: put the closing author's note under its own heading."""
|
|
520
|
+
i = _find_author_note(blocks)
|
|
521
|
+
if i is None:
|
|
522
|
+
return _strip_credits(blocks, res, None)
|
|
523
|
+
blocks = _strip_credits(blocks, res, i)
|
|
524
|
+
first = blocks[i]
|
|
525
|
+
m = re.match(rf"\s*{NOTE_LABEL}\s*[:.\-–—]?\s*", plain_text(first.inlines), re.I)
|
|
526
|
+
rest = _trim(_drop_chars(first.inlines, m.end() if m else 0))
|
|
527
|
+
note = [Rule(), Heading(1, "Author’s Note")] + ([Para(rest)] if plain_text(rest).strip() else [])
|
|
528
|
+
return blocks[:i] + note + blocks[i + 1:]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _strip_author_note(blocks: list, res: CleanResult) -> list:
|
|
532
|
+
for i, b in enumerate(blocks):
|
|
533
|
+
if not AUTHOR_NOTE.match(_text(b)):
|
|
534
|
+
continue
|
|
535
|
+
if i >= len(blocks) * 0.5:
|
|
536
|
+
res.log.append(f"removed author's note and everything after it ({len(blocks) - i} paragraphs)")
|
|
537
|
+
return blocks[:i]
|
|
538
|
+
end = _note_end(blocks, i)
|
|
539
|
+
res.log.append(f"removed a mid-chapter author's note: {_text(b)[:120]}")
|
|
540
|
+
return blocks[:i] + _strip_author_note(blocks[end:], res)
|
|
541
|
+
return blocks
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _normalise(blocks: list, chapter_marks: bool) -> list:
|
|
545
|
+
out: list = []
|
|
546
|
+
for b in blocks:
|
|
547
|
+
if _is_separator(b):
|
|
548
|
+
if out and not isinstance(out[-1], Rule):
|
|
549
|
+
out.append(Rule())
|
|
550
|
+
continue
|
|
551
|
+
if _is_blank(b):
|
|
552
|
+
continue
|
|
553
|
+
if chapter_marks and isinstance(b, Para) and CHAPTER_MARK.match(_text(b)):
|
|
554
|
+
if out and isinstance(out[-1], Rule):
|
|
555
|
+
out.pop()
|
|
556
|
+
out.append(Heading(1, _text(b).title()))
|
|
557
|
+
continue
|
|
558
|
+
if isinstance(b, Para):
|
|
559
|
+
b = Para(_trim(b.inlines))
|
|
560
|
+
out.append(b)
|
|
561
|
+
while out and isinstance(out[-1], Rule):
|
|
562
|
+
out.pop()
|
|
563
|
+
return out
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _trim(inlines: list) -> list:
|
|
567
|
+
"""Strip leading/trailing whitespace and line breaks from a paragraph."""
|
|
568
|
+
inl = list(inlines)
|
|
569
|
+
while inl and (isinstance(inl[0], Break) or (isinstance(inl[0], Text) and not inl[0].text.strip())):
|
|
570
|
+
inl.pop(0)
|
|
571
|
+
while inl and (isinstance(inl[-1], Break) or (isinstance(inl[-1], Text) and not inl[-1].text.strip())):
|
|
572
|
+
inl.pop()
|
|
573
|
+
if inl and isinstance(inl[0], Text):
|
|
574
|
+
inl[0] = replace(inl[0], text=inl[0].text.lstrip())
|
|
575
|
+
if inl and isinstance(inl[-1], Text):
|
|
576
|
+
inl[-1] = replace(inl[-1], text=inl[-1].text.rstrip())
|
|
577
|
+
return inl
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def clean(html: str, *, keep_notes: bool = False, chapter_marks: bool = False) -> CleanResult:
|
|
581
|
+
"""Clean one chapter's content HTML.
|
|
582
|
+
|
|
583
|
+
chapter_marks: turn standalone "Chapter N" paragraphs into headings (Griefman).
|
|
584
|
+
"""
|
|
585
|
+
res = CleanResult(blocks=[])
|
|
586
|
+
blocks = _remove_nav(html_to_blocks(html))
|
|
587
|
+
if keep_notes:
|
|
588
|
+
_find_alternates(blocks[:_leading_end(blocks)], res)
|
|
589
|
+
blocks = _mark_author_note(blocks, res)
|
|
590
|
+
else:
|
|
591
|
+
blocks = _strip_leading(blocks, res)
|
|
592
|
+
blocks = _strip_author_note(blocks, res)
|
|
593
|
+
blocks = _strip_trailing(blocks, res)
|
|
594
|
+
blocks = _strip_credits(blocks, res, None)
|
|
595
|
+
blocks = _normalise(blocks, chapter_marks)
|
|
596
|
+
if res.content_warning:
|
|
597
|
+
blocks.insert(0, Para([Em([Text(CONTENT_WARNING_TEXT)])]))
|
|
598
|
+
res.blocks = blocks
|
|
599
|
+
return res
|