bindery-cli 0.27.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.
- bindery/__init__.py +12 -0
- bindery/__main__.py +4 -0
- bindery/audit.py +2137 -0
- bindery/cli.py +961 -0
- bindery/epub.py +618 -0
- bindery/library.py +244 -0
- bindery/pagination.py +359 -0
- bindery/reserialize.py +42 -0
- bindery/transforms.py +579 -0
- bindery/validate.py +254 -0
- bindery/watermark.py +244 -0
- bindery_cli-0.27.0.dist-info/METADATA +218 -0
- bindery_cli-0.27.0.dist-info/RECORD +16 -0
- bindery_cli-0.27.0.dist-info/WHEEL +4 -0
- bindery_cli-0.27.0.dist-info/entry_points.txt +2 -0
- bindery_cli-0.27.0.dist-info/licenses/LICENSE +21 -0
bindery/validate.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""epubcheck wrapper and the acceptance gate.
|
|
2
|
+
|
|
3
|
+
epubcheck is the external oracle. A repair is only worth keeping if it strictly
|
|
4
|
+
reduces problems and never introduces new ones. If epubcheck is not installed, the
|
|
5
|
+
gate degrades safely: validation is skipped and callers must decide whether to trust
|
|
6
|
+
the repair without it (the CLI requires --no-validate to do so).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_SUMMARY_RE = re.compile(
|
|
20
|
+
r"Messages:\s*(\d+)\s+fatals?\s*/\s*(\d+)\s+errors?\s*/\s*(\d+)\s+warnings?"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CheckResult:
|
|
26
|
+
fatals: int
|
|
27
|
+
errors: int
|
|
28
|
+
warnings: int
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
return f"{self.fatals}f/{self.errors}e/{self.warnings}w"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def epubcheck_available() -> bool:
|
|
35
|
+
return shutil.which("epubcheck") is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _english_locale_env() -> dict[str, str]:
|
|
39
|
+
"""The subprocess env with the JVM pinned to English (roadmap 5.2).
|
|
40
|
+
|
|
41
|
+
epubcheck localizes its human-readable summary, which broke the regex
|
|
42
|
+
fallback on non-English locales. Appended rather than assigned so a
|
|
43
|
+
user's existing JAVA_TOOL_OPTIONS (heap flags etc.) survive.
|
|
44
|
+
"""
|
|
45
|
+
env = dict(os.environ)
|
|
46
|
+
opts = env.get("JAVA_TOOL_OPTIONS", "")
|
|
47
|
+
env["JAVA_TOOL_OPTIONS"] = f"{opts} -Duser.language=en -Duser.country=US".strip()
|
|
48
|
+
return env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _counts_from_json(stdout: str) -> CheckResult | None:
|
|
52
|
+
"""Parse counts from epubcheck's locale-independent `--json -` output.
|
|
53
|
+
|
|
54
|
+
epubcheck 5.x puts the totals under "checker" (nFatal/nError/nWarning,
|
|
55
|
+
verified against 5.3.0). Anything unexpected returns None so the caller
|
|
56
|
+
can fall back to the summary-line regex.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(stdout)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
return None
|
|
62
|
+
checker = data.get("checker") if isinstance(data, dict) else None
|
|
63
|
+
if not isinstance(checker, dict):
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
return CheckResult(
|
|
67
|
+
int(checker["nFatal"]), int(checker["nError"]), int(checker["nWarning"])
|
|
68
|
+
)
|
|
69
|
+
except KeyError, TypeError, ValueError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_DAEMON_JAVA = """
|
|
74
|
+
import java.io.File;
|
|
75
|
+
import java.io.PrintWriter;
|
|
76
|
+
import java.util.Scanner;
|
|
77
|
+
import com.adobe.epubcheck.api.EpubCheck;
|
|
78
|
+
import com.adobe.epubcheck.reporting.CheckingReport;
|
|
79
|
+
|
|
80
|
+
public class FastDaemon {
|
|
81
|
+
public static void main(String[] args) throws Exception {
|
|
82
|
+
Scanner scanner = new Scanner(System.in);
|
|
83
|
+
while (scanner.hasNextLine()) {
|
|
84
|
+
String path = scanner.nextLine();
|
|
85
|
+
if (path.trim().isEmpty()) continue;
|
|
86
|
+
File epub = new File(path);
|
|
87
|
+
if (!epub.exists()) {
|
|
88
|
+
System.out.println("-1,-1,-1");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
PrintWriter out = new PrintWriter(new java.io.OutputStream() {
|
|
93
|
+
public void write(int b) {}
|
|
94
|
+
});
|
|
95
|
+
CheckingReport report = new CheckingReport(out, epub.getName());
|
|
96
|
+
EpubCheck check = new EpubCheck(epub, report);
|
|
97
|
+
check.doValidate();
|
|
98
|
+
System.out.println(report.getFatalErrorCount() + "," + report.getErrorCount() + "," + report.getWarningCount());
|
|
99
|
+
} catch (Exception e) {
|
|
100
|
+
System.out.println("-1,-1,-1");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _EpubcheckDaemon:
|
|
109
|
+
def __init__(self):
|
|
110
|
+
self._proc = None
|
|
111
|
+
self._lock = __import__("threading").Lock()
|
|
112
|
+
|
|
113
|
+
def _start(self):
|
|
114
|
+
epubcheck_bin = shutil.which("epubcheck")
|
|
115
|
+
if not epubcheck_bin:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(epubcheck_bin) as f:
|
|
120
|
+
script = f.read()
|
|
121
|
+
m = re.search(
|
|
122
|
+
r"java\s+(?:-[^ ]+\s+)*-jar\s+[\"'\\]*([^\s\"'\\]+\.jar)", script
|
|
123
|
+
)
|
|
124
|
+
if not m:
|
|
125
|
+
return False
|
|
126
|
+
jar_path = os.path.expandvars(m.group(1))
|
|
127
|
+
if not os.path.exists(jar_path):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
self.workdir = __import__("tempfile").mkdtemp(prefix="bindery-daemon-")
|
|
131
|
+
java_file = os.path.join(self.workdir, "FastDaemon.java")
|
|
132
|
+
with open(java_file, "w") as f:
|
|
133
|
+
f.write(_DAEMON_JAVA)
|
|
134
|
+
|
|
135
|
+
subprocess.run(
|
|
136
|
+
["javac", "-cp", jar_path, java_file],
|
|
137
|
+
check=True,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self._proc = subprocess.Popen(
|
|
142
|
+
["java", "-cp", f".:{jar_path}", "FastDaemon"],
|
|
143
|
+
cwd=self.workdir,
|
|
144
|
+
stdin=subprocess.PIPE,
|
|
145
|
+
stdout=subprocess.PIPE,
|
|
146
|
+
stderr=subprocess.DEVNULL,
|
|
147
|
+
text=True,
|
|
148
|
+
bufsize=1,
|
|
149
|
+
)
|
|
150
|
+
__import__("atexit").register(self.stop)
|
|
151
|
+
return True
|
|
152
|
+
except Exception:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def check(self, path: Path) -> CheckResult | None:
|
|
156
|
+
with self._lock:
|
|
157
|
+
if self._proc is None:
|
|
158
|
+
if not self._start():
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
self._proc.stdin.write(str(path.resolve()) + "\n")
|
|
162
|
+
self._proc.stdin.flush()
|
|
163
|
+
res = self._proc.stdout.readline().strip()
|
|
164
|
+
if not res or res == "-1,-1,-1":
|
|
165
|
+
return None
|
|
166
|
+
f, e, w = map(int, res.split(","))
|
|
167
|
+
return CheckResult(f, e, w)
|
|
168
|
+
except Exception:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def stop(self):
|
|
172
|
+
if self._proc:
|
|
173
|
+
if self._proc.stdin:
|
|
174
|
+
try:
|
|
175
|
+
self._proc.stdin.close()
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
self._proc.terminate()
|
|
179
|
+
self._proc.wait(timeout=2)
|
|
180
|
+
self._proc = None
|
|
181
|
+
if hasattr(self, "workdir") and os.path.exists(self.workdir):
|
|
182
|
+
shutil.rmtree(self.workdir, ignore_errors=True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_daemon = _EpubcheckDaemon()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_epubcheck(path: Path, timeout: int = 300) -> CheckResult | None:
|
|
189
|
+
"""Run epubcheck and return parsed counts, or None if it could not be parsed.
|
|
190
|
+
|
|
191
|
+
Counts come from `--json -` (locale-independent) first; the English summary-line
|
|
192
|
+
regex stays as the fallback for epubchecks too old for `--json`, kept meaningful
|
|
193
|
+
by the JVM locale pin in the env. The persistent daemon answers first when it is
|
|
194
|
+
available; None anywhere means the caller falls back to (or reports) failure.
|
|
195
|
+
"""
|
|
196
|
+
res = _daemon.check(path)
|
|
197
|
+
if res is not None:
|
|
198
|
+
return res
|
|
199
|
+
try:
|
|
200
|
+
out = subprocess.run(
|
|
201
|
+
["epubcheck", str(path), "--json", "-"],
|
|
202
|
+
capture_output=True,
|
|
203
|
+
text=True,
|
|
204
|
+
timeout=timeout,
|
|
205
|
+
env=_english_locale_env(),
|
|
206
|
+
check=False,
|
|
207
|
+
)
|
|
208
|
+
except FileNotFoundError, subprocess.TimeoutExpired:
|
|
209
|
+
return None
|
|
210
|
+
result = _counts_from_json(out.stdout)
|
|
211
|
+
if result is not None:
|
|
212
|
+
return result
|
|
213
|
+
m = _SUMMARY_RE.search(out.stdout + out.stderr)
|
|
214
|
+
if not m:
|
|
215
|
+
return None
|
|
216
|
+
return CheckResult(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def gate(before: CheckResult, after: CheckResult) -> str:
|
|
220
|
+
"""Classify a repair: 'accept', 'partial', 'reject', or 'noop'.
|
|
221
|
+
|
|
222
|
+
The metric depends on whether the book started with fatals, because a fatal parse
|
|
223
|
+
error halts epubcheck on that file and hides every downstream schema error. So:
|
|
224
|
+
|
|
225
|
+
- Started WITH fatals: success is fewer fatals. A rising error count is just those
|
|
226
|
+
latent errors becoming visible once the file parses (the book now opens), not a
|
|
227
|
+
regression. 'accept' if all fatals cleared, 'partial' if merely reduced.
|
|
228
|
+
- Started with NO fatals (pure error/NCX-001 cleanup): nothing was masking errors,
|
|
229
|
+
so an error increase is a real regression. Require a strict error decrease.
|
|
230
|
+
|
|
231
|
+
Introducing net-new fatals is always a 'reject'.
|
|
232
|
+
"""
|
|
233
|
+
if after.fatals > before.fatals:
|
|
234
|
+
return "reject"
|
|
235
|
+
if before.fatals > 0:
|
|
236
|
+
if after.fatals == 0:
|
|
237
|
+
return "accept"
|
|
238
|
+
return "partial" if after.fatals < before.fatals else "noop"
|
|
239
|
+
if after.errors > before.errors:
|
|
240
|
+
return "reject"
|
|
241
|
+
return "accept" if after.errors < before.errors else "noop"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def no_worse(before: CheckResult, after: CheckResult) -> bool:
|
|
245
|
+
"""The acceptance bar for a lossy content repair (page-number stripping), whose
|
|
246
|
+
benefit epubcheck cannot see. Unlike `gate`, it does not demand a measured
|
|
247
|
+
improvement; it only forbids a regression: no net-new fatals, and no new errors
|
|
248
|
+
unless fatals were already masking them. Mirrors oceanstrip's 'no more fatals or
|
|
249
|
+
errors than the original' bar."""
|
|
250
|
+
if after.fatals > before.fatals:
|
|
251
|
+
return False
|
|
252
|
+
if before.fatals == 0 and after.errors > before.errors:
|
|
253
|
+
return False
|
|
254
|
+
return True
|
bindery/watermark.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Locate and remove producer/conversion watermarks from (X)HTML documents.
|
|
2
|
+
|
|
3
|
+
A watermark is a run of markup whose visible text is a known producer stamp.
|
|
4
|
+
Producers inject it two ways, and oceanstrip handles both with the same
|
|
5
|
+
balanced-element removal so a well-formed document stays well-formed:
|
|
6
|
+
|
|
7
|
+
- **Anchored** (a link): an <a> whose start tag (href) carries a known signature.
|
|
8
|
+
OceanofPDF appends `<a href="...oceanofpdf.com">OceanofPDF.com</a>`; ABC Amber
|
|
9
|
+
wraps `<b>Generated by ABC Amber LIT Conv<a href="...processtext...">erter,
|
|
10
|
+
...</a></b>` (the word "Converter" straddles the tag boundary). We find the <a>,
|
|
11
|
+
walk up to the OUTERMOST enclosing wrapper whose entire visible text is the
|
|
12
|
+
watermark, and delete that whole balanced element. If no such clean wrapper
|
|
13
|
+
exists (the link sits inline in real prose), only the <a> is removed.
|
|
14
|
+
|
|
15
|
+
- **Anchorless** (plain text): the stamp is literal text with the URL not linked,
|
|
16
|
+
e.g. `<p>ABC Amber LIT Converter http://www.processtext.com/abclit.html</p>` or a
|
|
17
|
+
`<div type="FOOTER"><p><span>...</span></p></div>`. There is no <a> to anchor on,
|
|
18
|
+
so we locate the stamp by a text signature and walk up to the outermost wrapper
|
|
19
|
+
whose entire visible text is the watermark (allowing the stamp to repeat), then
|
|
20
|
+
delete it. Real prose that merely mentions the URL is safe: its wrapper's text is
|
|
21
|
+
not *only* the watermark, so nothing is removed.
|
|
22
|
+
|
|
23
|
+
A flat regex cannot do either safely: it closes on the wrong tag and corrupts the
|
|
24
|
+
XML, or orphans the half of a phrase that sits outside the <a>. New producers are
|
|
25
|
+
one entry in WATERMARKS; the passes below are otherwise signature-agnostic.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Watermark:
|
|
36
|
+
"""A known producer watermark.
|
|
37
|
+
|
|
38
|
+
- ``href_sig``: lowercase substring expected in an anchored watermark's <a>
|
|
39
|
+
start tag (its href), or None if this producer never links the stamp.
|
|
40
|
+
- ``text_sig``: a lowercase substring of the stamp's *visible text* used to
|
|
41
|
+
locate an anchorless occurrence cheaply (e.g. "processtext.com/abclit").
|
|
42
|
+
- ``phrase``: a regex (case-insensitive) matching one occurrence of the stamp's
|
|
43
|
+
full visible text. A wrapper is removed only when its entire normalized text
|
|
44
|
+
is one or more repetitions of this phrase, which is what keeps real prose that
|
|
45
|
+
merely sits near the stamp untouched.
|
|
46
|
+
|
|
47
|
+
``anchor`` and ``pure`` are the compiled patterns, built in __post_init__.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
href_sig: str | None
|
|
52
|
+
text_sig: str
|
|
53
|
+
phrase: str
|
|
54
|
+
anchor: re.Pattern[str] | None = field(default=None, compare=False)
|
|
55
|
+
pure: re.Pattern[str] = field(default=None, compare=False) # type: ignore[assignment]
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
anchor = (
|
|
59
|
+
re.compile(
|
|
60
|
+
rf"<a\b[^>]*?{re.escape(self.href_sig)}[^>]*?>.*?</a>",
|
|
61
|
+
re.IGNORECASE | re.DOTALL,
|
|
62
|
+
)
|
|
63
|
+
if self.href_sig
|
|
64
|
+
else None
|
|
65
|
+
)
|
|
66
|
+
object.__setattr__(self, "anchor", anchor)
|
|
67
|
+
object.__setattr__(
|
|
68
|
+
self, "pure", re.compile(rf"(?:{self.phrase}\s*)+", re.IGNORECASE)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# The known watermarks. Append here to support a new producer; nothing else changes.
|
|
73
|
+
WATERMARKS: tuple[Watermark, ...] = (
|
|
74
|
+
Watermark(
|
|
75
|
+
name="OceanofPDF",
|
|
76
|
+
href_sig="oceanofpdf",
|
|
77
|
+
text_sig="oceanofpdf.com",
|
|
78
|
+
phrase=r"oceanofpdf\.com",
|
|
79
|
+
),
|
|
80
|
+
Watermark(
|
|
81
|
+
name="ABC Amber LIT Converter",
|
|
82
|
+
href_sig="processtext",
|
|
83
|
+
text_sig="processtext.com/abclit",
|
|
84
|
+
phrase=r"(?:generated by )?abc amber lit converter,?\s*"
|
|
85
|
+
r"https?://(?:www\.)?processtext\.com/abclit\.html",
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Any tag (open / close / self-closing), used for the balanced-element walk.
|
|
90
|
+
TAG = re.compile(r"<(/?)([a-zA-Z][\w:-]*)\b[^>]*?(/?)>", re.DOTALL)
|
|
91
|
+
|
|
92
|
+
# HTML void elements never have a closing tag; ignore them in balance tracking.
|
|
93
|
+
VOID = {
|
|
94
|
+
"area",
|
|
95
|
+
"base",
|
|
96
|
+
"br",
|
|
97
|
+
"col",
|
|
98
|
+
"embed",
|
|
99
|
+
"hr",
|
|
100
|
+
"img",
|
|
101
|
+
"input",
|
|
102
|
+
"link",
|
|
103
|
+
"meta",
|
|
104
|
+
"param",
|
|
105
|
+
"source",
|
|
106
|
+
"track",
|
|
107
|
+
"wbr",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Removable watermark wrappers. Never body/html/section, so a page whose sole *text*
|
|
111
|
+
# is the watermark (e.g. an image-only title page) is not deleted wholesale. The
|
|
112
|
+
# inline elements (b/i/em/strong) are here because ABC Amber wraps its stamp in a
|
|
113
|
+
# <b>; the "visible text is only the watermark" guard below keeps the wider set
|
|
114
|
+
# safe: a real heading/paragraph/bold phrase won't have text that is a watermark.
|
|
115
|
+
WRAPPERS = {
|
|
116
|
+
"div",
|
|
117
|
+
"p",
|
|
118
|
+
"span",
|
|
119
|
+
"h1",
|
|
120
|
+
"h2",
|
|
121
|
+
"h3",
|
|
122
|
+
"h4",
|
|
123
|
+
"h5",
|
|
124
|
+
"h6",
|
|
125
|
+
"b",
|
|
126
|
+
"i",
|
|
127
|
+
"em",
|
|
128
|
+
"strong",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# If a candidate wrapper contains any of these it holds real content. don't remove.
|
|
132
|
+
MEDIA = re.compile(
|
|
133
|
+
r"<(?:img|image|svg|video|audio|picture|iframe|table|object)\b", re.IGNORECASE
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _norm(element: str) -> str:
|
|
138
|
+
"""Visible text of an element fragment: tags stripped, nbsp and whitespace runs
|
|
139
|
+
collapsed to single spaces, stripped, lowercased."""
|
|
140
|
+
text = re.sub(r"<[^>]+>", "", element).replace("\xa0", " ")
|
|
141
|
+
return re.sub(r"\s+", " ", text).strip().lower()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _matching_close(html: str, name: str, open_end: int) -> tuple[int, int] | None:
|
|
145
|
+
"""Span of the close tag matching a `name` element already opened just before
|
|
146
|
+
`open_end` (which must point past that opening tag's '>')."""
|
|
147
|
+
depth = 1
|
|
148
|
+
pat = re.compile(
|
|
149
|
+
rf"<(/?)({re.escape(name)})\b[^>]*?(/?)>", re.IGNORECASE | re.DOTALL
|
|
150
|
+
)
|
|
151
|
+
for m in pat.finditer(html, open_end):
|
|
152
|
+
closing, _, selfclose = m.group(1), m.group(2), m.group(3)
|
|
153
|
+
if selfclose:
|
|
154
|
+
continue
|
|
155
|
+
if closing:
|
|
156
|
+
depth -= 1
|
|
157
|
+
if depth == 0:
|
|
158
|
+
return m.start(), m.end()
|
|
159
|
+
else:
|
|
160
|
+
depth += 1
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ancestors(html: str, a_start: int, a_end: int):
|
|
165
|
+
"""Yield (open_start, close_end) for wrapper elements enclosing [a_start, a_end],
|
|
166
|
+
outermost first."""
|
|
167
|
+
stack: list[tuple[str, int, int]] = []
|
|
168
|
+
for m in TAG.finditer(html, 0, a_start):
|
|
169
|
+
closing, name, selfclose = m.group(1), m.group(2).lower(), m.group(3)
|
|
170
|
+
if name in VOID or selfclose:
|
|
171
|
+
continue
|
|
172
|
+
if closing:
|
|
173
|
+
for i in range(len(stack) - 1, -1, -1):
|
|
174
|
+
if stack[i][0] == name:
|
|
175
|
+
del stack[i:]
|
|
176
|
+
break
|
|
177
|
+
else:
|
|
178
|
+
stack.append((name, m.start(), m.end()))
|
|
179
|
+
|
|
180
|
+
for name, ostart, oend in stack:
|
|
181
|
+
if name not in WRAPPERS:
|
|
182
|
+
continue
|
|
183
|
+
close = _matching_close(html, name, oend)
|
|
184
|
+
if close and close[0] >= a_end:
|
|
185
|
+
yield ostart, close[1]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pure_wrapper_span(
|
|
189
|
+
html: str, start: int, end: int, wm: Watermark
|
|
190
|
+
) -> tuple[int, int] | None:
|
|
191
|
+
"""The outermost wrapper enclosing [start, end] whose entire visible text is the
|
|
192
|
+
watermark (one or more repetitions) and which holds no media; else None."""
|
|
193
|
+
for ostart, cend in _ancestors(html, start, end): # outermost first
|
|
194
|
+
element = html[ostart:cend]
|
|
195
|
+
if MEDIA.search(element):
|
|
196
|
+
continue
|
|
197
|
+
if wm.pure.fullmatch(_norm(element)):
|
|
198
|
+
return ostart, cend
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _strip_anchored(html: str, wm: Watermark) -> tuple[str, int]:
|
|
203
|
+
"""Remove anchored (linked) occurrences of `wm`. Deletes the outermost pure
|
|
204
|
+
wrapper around each watermark <a>, or the <a> itself when it sits inline."""
|
|
205
|
+
if wm.anchor is None:
|
|
206
|
+
return html, 0
|
|
207
|
+
count = 0
|
|
208
|
+
while (m := wm.anchor.search(html)) is not None:
|
|
209
|
+
span = _pure_wrapper_span(html, m.start(), m.end(), wm)
|
|
210
|
+
s, e = span if span else (m.start(), m.end())
|
|
211
|
+
html = html[:s] + html[e:]
|
|
212
|
+
count += 1
|
|
213
|
+
return html, count
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _strip_text(html: str, wm: Watermark) -> tuple[str, int]:
|
|
217
|
+
"""Remove anchorless (plain-text) occurrences of `wm`. Finds the stamp by its
|
|
218
|
+
text signature and deletes the outermost wrapper whose whole text is the
|
|
219
|
+
watermark; an occurrence with no such wrapper (the URL sits in real prose) is
|
|
220
|
+
left untouched."""
|
|
221
|
+
count = 0
|
|
222
|
+
pos = 0
|
|
223
|
+
while (i := html.lower().find(wm.text_sig, pos)) != -1:
|
|
224
|
+
span = _pure_wrapper_span(html, i, i + len(wm.text_sig), wm)
|
|
225
|
+
if span is None:
|
|
226
|
+
pos = i + len(wm.text_sig) # not a pure watermark wrapper; skip it
|
|
227
|
+
continue
|
|
228
|
+
html = html[: span[0]] + html[span[1] :]
|
|
229
|
+
count += 1
|
|
230
|
+
pos = 0
|
|
231
|
+
return html, count
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def strip_watermark_html(html: str) -> tuple[str, int]:
|
|
235
|
+
"""Remove every known watermark (anchored and anchorless) from one document.
|
|
236
|
+
|
|
237
|
+
Returns (cleaned_html, number_of_watermarks_removed).
|
|
238
|
+
"""
|
|
239
|
+
total = 0
|
|
240
|
+
for wm in WATERMARKS:
|
|
241
|
+
html, n_anchor = _strip_anchored(html, wm)
|
|
242
|
+
html, n_text = _strip_text(html, wm)
|
|
243
|
+
total += n_anchor + n_text
|
|
244
|
+
return html, total
|