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 ADDED
@@ -0,0 +1,3 @@
1
+ """Download The Wandering Inn as clean Markdown or EPUB."""
2
+
3
+ __version__ = "0.1.0"
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