htomd 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.
- htomd/__init__.py +37 -0
- htomd/__main__.py +6 -0
- htomd/_cli.py +92 -0
- htomd/_metadata.py +137 -0
- htomd/_models.py +28 -0
- htomd/_parser.py +133 -0
- htomd/_render.py +270 -0
- htomd/_selection.py +339 -0
- htomd/_tree.py +53 -0
- htomd/_urls.py +43 -0
- htomd/py.typed +0 -0
- htomd-0.1.0.dist-info/METADATA +109 -0
- htomd-0.1.0.dist-info/RECORD +17 -0
- htomd-0.1.0.dist-info/WHEEL +5 -0
- htomd-0.1.0.dist-info/entry_points.txt +2 -0
- htomd-0.1.0.dist-info/licenses/LICENSE +21 -0
- htomd-0.1.0.dist-info/top_level.txt +1 -0
htomd/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Focused Markdown and metadata from decoded HTML, with no network access."""
|
|
2
|
+
|
|
3
|
+
from ._metadata import read_metadata, refine_metadata
|
|
4
|
+
from ._models import Diagnostics, Document, Metadata
|
|
5
|
+
from ._parser import parse
|
|
6
|
+
from ._render import render
|
|
7
|
+
from ._selection import select
|
|
8
|
+
from ._urls import document_base
|
|
9
|
+
|
|
10
|
+
__all__ = ["Diagnostics", "Document", "Metadata", "convert", "extract"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract(html: str, *, url: str | None = None) -> Document:
|
|
14
|
+
"""Extract relevant Markdown and explicit metadata from an HTML string.
|
|
15
|
+
|
|
16
|
+
``url`` provides source context and resolves references; no fetching occurs.
|
|
17
|
+
Malformed HTML receives best-effort recovery. Result dataclasses are immutable.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(html, str):
|
|
20
|
+
raise TypeError("html must be a decoded str")
|
|
21
|
+
if url is not None and not isinstance(url, str):
|
|
22
|
+
raise TypeError("url must be a str or None")
|
|
23
|
+
root, parsing_notes = parse(html)
|
|
24
|
+
base = document_base(root, url)
|
|
25
|
+
metadata = read_metadata(root, url, base)
|
|
26
|
+
selected, diagnostics = select(root)
|
|
27
|
+
metadata = refine_metadata(metadata, selected)
|
|
28
|
+
markdown = render(selected, base)
|
|
29
|
+
diagnostics = Diagnostics(
|
|
30
|
+
diagnostics.strategy if markdown else "none", parsing_notes + diagnostics.notes
|
|
31
|
+
)
|
|
32
|
+
return Document(markdown, metadata, diagnostics)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert(html: str, *, url: str | None = None) -> str:
|
|
36
|
+
"""Return the Markdown produced by :func:`extract`."""
|
|
37
|
+
return extract(html, url=url).markdown
|
htomd/__main__.py
ADDED
htomd/_cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command-line conversion of HTML supplied on stdin."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
from . import convert, extract
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="htomd",
|
|
16
|
+
description=(
|
|
17
|
+
"Extract Markdown and metadata from UTF-8 HTML on stdin. "
|
|
18
|
+
"Pipe input from cat or curl; output goes to stdout. No file arguments or fetching."
|
|
19
|
+
),
|
|
20
|
+
epilog=(
|
|
21
|
+
"Examples:\n"
|
|
22
|
+
" cat page.html | htomd convert > page.md\n"
|
|
23
|
+
" cat page.html | htomd extract > page.json\n"
|
|
24
|
+
" curl -fsSL https://example.com | htomd convert --url https://example.com\n"
|
|
25
|
+
"\nUse 'htomd help COMMAND' for command-specific help."
|
|
26
|
+
),
|
|
27
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
28
|
+
)
|
|
29
|
+
version_text = f"htomd {version('htomd')}"
|
|
30
|
+
parser.add_argument("--version", action="version", version=version_text)
|
|
31
|
+
commands = parser.add_subparsers(dest="command")
|
|
32
|
+
command_parsers: dict[str, argparse.ArgumentParser] = {}
|
|
33
|
+
for name, description in (
|
|
34
|
+
("convert", "Write Markdown to stdout."),
|
|
35
|
+
("extract", "Write Markdown, metadata, and diagnostics as JSON to stdout."),
|
|
36
|
+
):
|
|
37
|
+
command = commands.add_parser(
|
|
38
|
+
name,
|
|
39
|
+
help=description,
|
|
40
|
+
description=f"Read UTF-8 HTML from stdin to EOF. {description}",
|
|
41
|
+
epilog=f"Example: cat page.html | htomd {name}",
|
|
42
|
+
)
|
|
43
|
+
command.add_argument(
|
|
44
|
+
"--url", help="Source URL for resolving references; no fetching occurs."
|
|
45
|
+
)
|
|
46
|
+
command_parsers[name] = command
|
|
47
|
+
command_parsers["version"] = commands.add_parser(
|
|
48
|
+
"version",
|
|
49
|
+
help="Show the installed package version.",
|
|
50
|
+
description="Show the installed version.",
|
|
51
|
+
)
|
|
52
|
+
help_parser = commands.add_parser(
|
|
53
|
+
"help",
|
|
54
|
+
help="Show general or command-specific help.",
|
|
55
|
+
description="Show help for a command.",
|
|
56
|
+
)
|
|
57
|
+
command_parsers["help"] = help_parser
|
|
58
|
+
help_parser.add_argument(
|
|
59
|
+
"topic", nargs="?", choices=command_parsers, help="Command to describe."
|
|
60
|
+
)
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
if args.command is None or args.command == "help":
|
|
64
|
+
topic = args.topic if args.command == "help" else None
|
|
65
|
+
(command_parsers[topic] if topic else parser).print_help()
|
|
66
|
+
return 0
|
|
67
|
+
if args.command == "version":
|
|
68
|
+
print(version_text)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
html = sys.stdin.buffer.read().decode("utf-8-sig")
|
|
73
|
+
if args.command == "convert":
|
|
74
|
+
output = convert(html, url=args.url)
|
|
75
|
+
else:
|
|
76
|
+
output = json.dumps(asdict(extract(html, url=args.url)), ensure_ascii=False, indent=2)
|
|
77
|
+
output += "\n"
|
|
78
|
+
except (OSError, UnicodeError) as error:
|
|
79
|
+
print(f"htomd: {error}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
84
|
+
sys.stdout.buffer.flush()
|
|
85
|
+
except OSError as error:
|
|
86
|
+
# Prevent a second output failure when Python flushes stdout during shutdown.
|
|
87
|
+
with open(os.devnull, "wb") as sink:
|
|
88
|
+
os.dup2(sink.fileno(), sys.stdout.fileno())
|
|
89
|
+
if not isinstance(error, BrokenPipeError):
|
|
90
|
+
print(f"htomd: {error}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
return 0
|
htomd/_metadata.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Read explicit metadata before cleanup, then refine it from selected content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from ._models import Metadata
|
|
10
|
+
from ._tree import Node, text_content, walk
|
|
11
|
+
from ._urls import safe_url
|
|
12
|
+
|
|
13
|
+
ARTICLE_TYPES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"Article",
|
|
16
|
+
"NewsArticle",
|
|
17
|
+
"BlogPosting",
|
|
18
|
+
"TechArticle",
|
|
19
|
+
"ScholarlyArticle",
|
|
20
|
+
"MedicalScholarlyArticle",
|
|
21
|
+
"Report",
|
|
22
|
+
"AnalysisNewsArticle",
|
|
23
|
+
"OpinionNewsArticle",
|
|
24
|
+
"ReviewNewsArticle",
|
|
25
|
+
"BackgroundNewsArticle",
|
|
26
|
+
"APIReference",
|
|
27
|
+
"LiveBlogPosting",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def string(value: object) -> str | None:
|
|
33
|
+
return value.strip() or None if isinstance(value, str) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def json_articles(value: object) -> Iterator[dict[str, object]]:
|
|
37
|
+
stack = [value]
|
|
38
|
+
while stack:
|
|
39
|
+
item = stack.pop()
|
|
40
|
+
if isinstance(item, list):
|
|
41
|
+
stack.extend(reversed(item))
|
|
42
|
+
elif isinstance(item, dict):
|
|
43
|
+
kind = item.get("@type", [])
|
|
44
|
+
kinds = [kind] if isinstance(kind, str) else kind
|
|
45
|
+
if isinstance(kinds, list) and any(
|
|
46
|
+
isinstance(entry, str) and entry.rsplit("/", 1)[-1] in ARTICLE_TYPES
|
|
47
|
+
for entry in kinds
|
|
48
|
+
):
|
|
49
|
+
yield item
|
|
50
|
+
graph = item.get("@graph")
|
|
51
|
+
if isinstance(graph, (dict, list)):
|
|
52
|
+
stack.append(graph)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def author_name(value: object) -> str | None:
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
return string(value)
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return string(value.get("name"))
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
names = [name for entry in value if (name := author_name(entry))]
|
|
62
|
+
return ", ".join(names) or None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_jsonld(root: Node) -> dict[str, object]:
|
|
67
|
+
for node in walk(root):
|
|
68
|
+
if node.tag != "script" or node.attrs.get("type", "").lower() != "application/ld+json":
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(text_content(node, normalize=False))
|
|
72
|
+
except (ValueError, RecursionError):
|
|
73
|
+
continue
|
|
74
|
+
article = next(json_articles(value), None)
|
|
75
|
+
if article is not None:
|
|
76
|
+
return article
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_metadata(root: Node, url: str | None, base: str | None) -> Metadata:
|
|
81
|
+
fields: dict[str, str] = {}
|
|
82
|
+
title = language = canonical = None
|
|
83
|
+
for node in walk(root):
|
|
84
|
+
if node.tag == "meta":
|
|
85
|
+
key = node.attrs.get("property", node.attrs.get("name", "")).lower()
|
|
86
|
+
content = string(node.attrs.get("content"))
|
|
87
|
+
if content:
|
|
88
|
+
fields.setdefault(key, content)
|
|
89
|
+
elif node.tag == "title" and title is None:
|
|
90
|
+
title = string(text_content(node))
|
|
91
|
+
elif node.tag == "html":
|
|
92
|
+
language = string(node.attrs.get("lang") or node.attrs.get("xml:lang"))
|
|
93
|
+
elif node.tag == "link" and "canonical" in node.attrs.get("rel", "").lower().split():
|
|
94
|
+
canonical = canonical or safe_url(node.attrs.get("href", ""), base) or None
|
|
95
|
+
article = read_jsonld(root)
|
|
96
|
+
return Metadata(
|
|
97
|
+
title=fields.get("og:title") or title or string(article.get("headline")),
|
|
98
|
+
author=fields.get("author") or author_name(article.get("author")),
|
|
99
|
+
description=fields.get("description")
|
|
100
|
+
or fields.get("og:description")
|
|
101
|
+
or string(article.get("description")),
|
|
102
|
+
language=language or string(article.get("inLanguage")) or fields.get("og:locale"),
|
|
103
|
+
published_time=fields.get("article:published_time")
|
|
104
|
+
or fields.get("date")
|
|
105
|
+
or string(article.get("datePublished")),
|
|
106
|
+
url=url,
|
|
107
|
+
canonical_url=canonical,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def refine_metadata(metadata: Metadata, selected: list[Node]) -> Metadata:
|
|
112
|
+
heading = author = published = None
|
|
113
|
+
for root in selected:
|
|
114
|
+
for node in walk(root):
|
|
115
|
+
if node.tag == "h1" and heading is None:
|
|
116
|
+
heading = string(text_content(node))
|
|
117
|
+
if author is None and local_author(node):
|
|
118
|
+
author = string(text_content(node))
|
|
119
|
+
if (
|
|
120
|
+
published is None
|
|
121
|
+
and node.tag == "time"
|
|
122
|
+
and node.attrs.get("itemprop") != "dateModified"
|
|
123
|
+
):
|
|
124
|
+
published = string(node.attrs.get("datetime")) or string(text_content(node))
|
|
125
|
+
return replace(
|
|
126
|
+
metadata,
|
|
127
|
+
title=heading or metadata.title,
|
|
128
|
+
author=metadata.author or author,
|
|
129
|
+
published_time=metadata.published_time or published,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def local_author(node: Node) -> bool:
|
|
134
|
+
if node.attrs.get("itemprop") == "author" or "author" in node.attrs.get("rel", "").split():
|
|
135
|
+
return True
|
|
136
|
+
tokens = node.attrs.get("class", "").lower().split()
|
|
137
|
+
return any(token in {"byline", "author", "p-author"} for token in tokens)
|
htomd/_models.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Immutable public results; the HTML tree is deliberately private."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Metadata:
|
|
9
|
+
title: str | None = None
|
|
10
|
+
author: str | None = None
|
|
11
|
+
description: str | None = None
|
|
12
|
+
language: str | None = None
|
|
13
|
+
published_time: str | None = None
|
|
14
|
+
url: str | None = None
|
|
15
|
+
canonical_url: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Diagnostics:
|
|
20
|
+
strategy: Literal["semantic", "scored", "fallback", "none"]
|
|
21
|
+
notes: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Document:
|
|
26
|
+
markdown: str
|
|
27
|
+
metadata: Metadata
|
|
28
|
+
diagnostics: Diagnostics
|
htomd/_parser.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Small HTMLParser-based recovery, without pretending to implement HTML5."""
|
|
2
|
+
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
|
|
5
|
+
from ._tree import Node
|
|
6
|
+
|
|
7
|
+
VOID = frozenset(
|
|
8
|
+
[
|
|
9
|
+
"area",
|
|
10
|
+
"base",
|
|
11
|
+
"br",
|
|
12
|
+
"col",
|
|
13
|
+
"embed",
|
|
14
|
+
"hr",
|
|
15
|
+
"img",
|
|
16
|
+
"input",
|
|
17
|
+
"link",
|
|
18
|
+
"meta",
|
|
19
|
+
"param",
|
|
20
|
+
"source",
|
|
21
|
+
"track",
|
|
22
|
+
"wbr",
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
P_BREAKERS = frozenset(
|
|
26
|
+
[
|
|
27
|
+
"address",
|
|
28
|
+
"article",
|
|
29
|
+
"aside",
|
|
30
|
+
"blockquote",
|
|
31
|
+
"div",
|
|
32
|
+
"dl",
|
|
33
|
+
"fieldset",
|
|
34
|
+
"footer",
|
|
35
|
+
"form",
|
|
36
|
+
"h1",
|
|
37
|
+
"h2",
|
|
38
|
+
"h3",
|
|
39
|
+
"h4",
|
|
40
|
+
"h5",
|
|
41
|
+
"h6",
|
|
42
|
+
"header",
|
|
43
|
+
"hr",
|
|
44
|
+
"main",
|
|
45
|
+
"nav",
|
|
46
|
+
"ol",
|
|
47
|
+
"p",
|
|
48
|
+
"pre",
|
|
49
|
+
"section",
|
|
50
|
+
"table",
|
|
51
|
+
"ul",
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
# A matching optional end tag must be found before its enclosing scope boundary.
|
|
55
|
+
IMPLIED: dict[str, tuple[frozenset[str], frozenset[str]]] = {
|
|
56
|
+
"li": (frozenset({"li"}), frozenset({"ul", "ol"})),
|
|
57
|
+
"dt": (frozenset({"dt", "dd"}), frozenset({"dl"})),
|
|
58
|
+
"dd": (frozenset({"dt", "dd"}), frozenset({"dl"})),
|
|
59
|
+
"tr": (frozenset({"tr"}), frozenset({"table", "tbody", "thead", "tfoot"})),
|
|
60
|
+
"td": (frozenset({"td", "th"}), frozenset({"tr", "table"})),
|
|
61
|
+
"th": (frozenset({"td", "th"}), frozenset({"tr", "table"})),
|
|
62
|
+
"thead": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
|
|
63
|
+
"tbody": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
|
|
64
|
+
"tfoot": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
|
|
65
|
+
"option": (frozenset({"option"}), frozenset({"select", "datalist"})),
|
|
66
|
+
}
|
|
67
|
+
END_SCOPES = {"li": {"ul", "ol"}, "td": {"tr", "table"}, "th": {"tr", "table"}, "tr": {"table"}}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TreeParser(HTMLParser):
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
super().__init__(convert_charrefs=True)
|
|
73
|
+
self.root = Node("#document")
|
|
74
|
+
self.stack = [self.root]
|
|
75
|
+
self.recoveries = 0
|
|
76
|
+
|
|
77
|
+
def close_in_scope(self, targets: frozenset[str], boundaries: frozenset[str]) -> None:
|
|
78
|
+
for index in range(len(self.stack) - 1, 0, -1):
|
|
79
|
+
tag = self.stack[index].tag
|
|
80
|
+
if tag in targets:
|
|
81
|
+
del self.stack[index:]
|
|
82
|
+
self.recoveries += 1
|
|
83
|
+
return
|
|
84
|
+
if tag in boundaries:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
88
|
+
if tag in P_BREAKERS:
|
|
89
|
+
self.close_in_scope(frozenset({"p"}), frozenset({"table", "td", "th", "li"}))
|
|
90
|
+
if tag in IMPLIED:
|
|
91
|
+
self.close_in_scope(*IMPLIED[tag])
|
|
92
|
+
if tag == "a":
|
|
93
|
+
self.close_in_scope(frozenset({"a"}), frozenset({"p", "div", "li"}))
|
|
94
|
+
parent = self.stack[-1]
|
|
95
|
+
node = Node(tag, {name: value or "" for name, value in attrs}, parent=parent)
|
|
96
|
+
parent.children.append(node)
|
|
97
|
+
if tag not in VOID:
|
|
98
|
+
self.stack.append(node)
|
|
99
|
+
|
|
100
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
101
|
+
self.handle_starttag(tag, attrs)
|
|
102
|
+
if tag not in VOID:
|
|
103
|
+
self.handle_endtag(tag)
|
|
104
|
+
|
|
105
|
+
def handle_endtag(self, tag: str) -> None:
|
|
106
|
+
if tag in VOID:
|
|
107
|
+
return
|
|
108
|
+
for index in range(len(self.stack) - 1, 0, -1):
|
|
109
|
+
current = self.stack[index].tag
|
|
110
|
+
if current == tag:
|
|
111
|
+
del self.stack[index:]
|
|
112
|
+
return
|
|
113
|
+
if current in END_SCOPES.get(tag, set()):
|
|
114
|
+
break
|
|
115
|
+
self.recoveries += 1
|
|
116
|
+
|
|
117
|
+
def handle_data(self, data: str) -> None:
|
|
118
|
+
self.stack[-1].children.append(data)
|
|
119
|
+
|
|
120
|
+
def unknown_decl(self, data: str) -> None:
|
|
121
|
+
self.recoveries += 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse(html: str) -> tuple[Node, tuple[str, ...]]:
|
|
125
|
+
parser = TreeParser()
|
|
126
|
+
try:
|
|
127
|
+
parser.feed(html)
|
|
128
|
+
parser.close()
|
|
129
|
+
except (AssertionError, ValueError):
|
|
130
|
+
# HTMLParser rejects malformed marked sections. Preserve the parsed prefix.
|
|
131
|
+
parser.recoveries += 1
|
|
132
|
+
notes = ("Recovered malformed or optionally closed HTML.",) if parser.recoveries else ()
|
|
133
|
+
return parser.root, notes
|
htomd/_render.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Stack-safe Markdown serialization, independent of selection heuristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ._selection import HEADINGS
|
|
8
|
+
from ._tree import SPACE, Node, elements, postorder, text_content
|
|
9
|
+
from ._urls import destination, safe_url
|
|
10
|
+
|
|
11
|
+
EMPHASIS = {"em": "*", "i": "*", "strong": "**", "b": "**", "s": "~~", "del": "~~", "strike": "~~"}
|
|
12
|
+
BLOCKS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"p",
|
|
15
|
+
"div",
|
|
16
|
+
"article",
|
|
17
|
+
"main",
|
|
18
|
+
"section",
|
|
19
|
+
"header",
|
|
20
|
+
"footer",
|
|
21
|
+
"figure",
|
|
22
|
+
"figcaption",
|
|
23
|
+
"address",
|
|
24
|
+
"details",
|
|
25
|
+
"summary",
|
|
26
|
+
"dl",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
MARKUP = re.compile(r"([\\`*_~\[\]])")
|
|
30
|
+
BLOCK_START = re.compile(r"(^|\n)(\s*)(#{1,6}(?=\s)|[-+](?=\s)|\d+[.)](?=\s)|[=~-]{3,}(?=\s|$))")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def escape(value: str) -> str:
|
|
34
|
+
value = value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
35
|
+
value = MARKUP.sub(r"\\\1", value)
|
|
36
|
+
return BLOCK_START.sub(escape_block_start, value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def escape_block_start(match: re.Match[str]) -> str:
|
|
40
|
+
marker = match[3]
|
|
41
|
+
if marker[0].isdigit():
|
|
42
|
+
marker = marker[:-1] + "\\" + marker[-1]
|
|
43
|
+
else:
|
|
44
|
+
marker = "\\" + marker
|
|
45
|
+
return match[1] + match[2] + marker
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def longest_run(value: str, character: str) -> int:
|
|
49
|
+
return max(
|
|
50
|
+
(len(match[0]) for match in re.finditer(re.escape(character) + "+", value)), default=0
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def inline_code(node: Node) -> str:
|
|
55
|
+
value = (
|
|
56
|
+
text_content(node, normalize=False)
|
|
57
|
+
.replace("\r\n", " ")
|
|
58
|
+
.replace("\r", " ")
|
|
59
|
+
.replace("\n", " ")
|
|
60
|
+
)
|
|
61
|
+
if not value:
|
|
62
|
+
return ""
|
|
63
|
+
delimiter = "`" * (longest_run(value, "`") + 1)
|
|
64
|
+
padding = (
|
|
65
|
+
" "
|
|
66
|
+
if value.startswith("`")
|
|
67
|
+
or value.endswith("`")
|
|
68
|
+
or (value.startswith(" ") and value.endswith(" ") and value.strip())
|
|
69
|
+
else ""
|
|
70
|
+
)
|
|
71
|
+
return delimiter + padding + value + padding + delimiter
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def code_language(node: Node) -> str:
|
|
75
|
+
candidates = [node, *elements(node)]
|
|
76
|
+
if node.parent is not None:
|
|
77
|
+
candidates.append(node.parent)
|
|
78
|
+
for candidate in candidates:
|
|
79
|
+
for token in candidate.attrs.get("class", "").split():
|
|
80
|
+
if token.startswith(("language-", "lang-", "highlight-")):
|
|
81
|
+
value = token.split("-", 1)[1]
|
|
82
|
+
if re.fullmatch(r"[\w.+#-]+", value):
|
|
83
|
+
return value
|
|
84
|
+
value = candidate.attrs.get("data-language", "")
|
|
85
|
+
if re.fullmatch(r"[\w.+#-]+", value):
|
|
86
|
+
return value
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fenced_code(node: Node) -> str:
|
|
91
|
+
value = text_content(node, normalize=False).replace("\r\n", "\n").replace("\r", "\n")
|
|
92
|
+
fence = "`" * max(3, longest_run(value, "`") + 1)
|
|
93
|
+
ending = "" if value.endswith("\n") else "\n"
|
|
94
|
+
return f"\n\n{fence}{code_language(node)}\n{value}{ending}{fence}\n\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def join_parts(parts: list[str]) -> str:
|
|
98
|
+
"""Coalesce block boundaries without touching whitespace inside code blocks."""
|
|
99
|
+
result: list[str] = []
|
|
100
|
+
for part in parts:
|
|
101
|
+
if not part:
|
|
102
|
+
continue
|
|
103
|
+
if result and result[-1].endswith("\n") and part.startswith("\n"):
|
|
104
|
+
result[-1] = result[-1].rstrip("\n")
|
|
105
|
+
part = "\n\n" + part.lstrip("\n")
|
|
106
|
+
result.append(part)
|
|
107
|
+
return "".join(result)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def wrap_inline(body: str, marker: str) -> str:
|
|
111
|
+
content = body.strip()
|
|
112
|
+
if not content:
|
|
113
|
+
return body
|
|
114
|
+
leading = " " if body[:1].isspace() else ""
|
|
115
|
+
trailing = " " if body[-1:].isspace() else ""
|
|
116
|
+
return leading + marker + content + marker + trailing
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_link(node: Node, body: str, base: str | None) -> str:
|
|
120
|
+
href = safe_url(node.attrs.get("href", ""), base)
|
|
121
|
+
if not href or not body.strip():
|
|
122
|
+
return body
|
|
123
|
+
return f"[{body.strip()}]({destination(href)})"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_image(node: Node, base: str | None) -> str:
|
|
127
|
+
alt = escape(SPACE.sub(" ", node.attrs.get("alt", "")).strip())
|
|
128
|
+
source = safe_url(node.attrs.get("src", ""), base)
|
|
129
|
+
if not source or not node.attrs.get("src"):
|
|
130
|
+
return alt
|
|
131
|
+
return f"})"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_list(node: Node, rendered: dict[Node, str]) -> str:
|
|
135
|
+
try:
|
|
136
|
+
number = int(node.attrs.get("start", "1"))
|
|
137
|
+
except ValueError:
|
|
138
|
+
number = 1
|
|
139
|
+
items = []
|
|
140
|
+
for child in elements(node):
|
|
141
|
+
if child.tag != "li":
|
|
142
|
+
continue
|
|
143
|
+
if node.tag == "ol":
|
|
144
|
+
value = child.attrs.get("value", "")
|
|
145
|
+
if re.fullmatch(r"-?\d{1,9}", value):
|
|
146
|
+
number = int(value)
|
|
147
|
+
marker = f"{number}. " if node.tag == "ol" else "- "
|
|
148
|
+
lines = rendered[child].strip().splitlines()
|
|
149
|
+
if lines:
|
|
150
|
+
continuation = [" " * len(marker) + line if line else "" for line in lines[1:]]
|
|
151
|
+
items.append("\n".join([marker + lines[0], *continuation]))
|
|
152
|
+
number += 1
|
|
153
|
+
return "\n" + "\n".join(items) + "\n" if items else ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def table_rows(node: Node) -> list[Node]:
|
|
157
|
+
rows = []
|
|
158
|
+
stack = list(reversed(elements(node)))
|
|
159
|
+
while stack:
|
|
160
|
+
child = stack.pop()
|
|
161
|
+
if child.tag == "tr":
|
|
162
|
+
rows.append(child)
|
|
163
|
+
elif child.tag in {"thead", "tbody", "tfoot"}:
|
|
164
|
+
stack.extend(reversed(elements(child)))
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def simple_table(rows: list[list[Node]]) -> bool:
|
|
169
|
+
if not rows or not rows[0] or len({len(row) for row in rows}) != 1:
|
|
170
|
+
return False
|
|
171
|
+
for row in rows:
|
|
172
|
+
for cell in row:
|
|
173
|
+
if cell.attrs.get("colspan", "1") != "1" or cell.attrs.get("rowspan", "1") != "1":
|
|
174
|
+
return False
|
|
175
|
+
if any(child.tag in {"table", "pre", "ul", "ol", "p"} for child in elements(cell)):
|
|
176
|
+
return False
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_table(node: Node, rendered: dict[Node, str]) -> str:
|
|
181
|
+
rows = [
|
|
182
|
+
[cell for cell in elements(row) if cell.tag in {"th", "td"}] for row in table_rows(node)
|
|
183
|
+
]
|
|
184
|
+
captions = [rendered[child].strip() for child in elements(node) if child.tag == "caption"]
|
|
185
|
+
if not simple_table(rows):
|
|
186
|
+
lines = []
|
|
187
|
+
for index, row in enumerate(rows, 1):
|
|
188
|
+
cell_values = [rendered[cell].strip().replace("\n", " ") for cell in row]
|
|
189
|
+
lines.append(f"{index}. " + " — ".join(cell_values))
|
|
190
|
+
return "\n\n" + "\n\n".join([*captions, "\n".join(lines)]) + "\n\n"
|
|
191
|
+
values = [
|
|
192
|
+
[rendered[cell].strip().replace("|", "\\|").replace("\n", " ") for cell in row]
|
|
193
|
+
for row in rows
|
|
194
|
+
]
|
|
195
|
+
if not any(cell.tag == "th" for cell in rows[0]):
|
|
196
|
+
values.insert(0, [""] * len(rows[0]))
|
|
197
|
+
values.insert(1, ["---"] * len(rows[0]))
|
|
198
|
+
lines = ["| " + " | ".join(row) + " |" for row in values]
|
|
199
|
+
return "\n\n" + "\n\n".join([*captions, "\n".join(lines)]) + "\n\n"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def serialize(node: Node, body: str, rendered: dict[Node, str], base: str | None) -> str:
|
|
203
|
+
tag = node.tag
|
|
204
|
+
if tag in HEADINGS:
|
|
205
|
+
return "\n\n" + "#" * int(tag[1]) + " " + body.strip() + "\n\n" if body.strip() else ""
|
|
206
|
+
if tag in EMPHASIS:
|
|
207
|
+
return wrap_inline(body, EMPHASIS[tag])
|
|
208
|
+
if tag in {"code", "kbd", "samp"}:
|
|
209
|
+
return inline_code(node)
|
|
210
|
+
if tag == "pre":
|
|
211
|
+
return fenced_code(node)
|
|
212
|
+
if tag == "a":
|
|
213
|
+
return render_link(node, body, base)
|
|
214
|
+
if tag == "img":
|
|
215
|
+
return render_image(node, base)
|
|
216
|
+
if tag in {"ul", "ol"}:
|
|
217
|
+
return render_list(node, rendered)
|
|
218
|
+
if tag == "table":
|
|
219
|
+
return render_table(node, rendered)
|
|
220
|
+
return serialize_block(tag, body)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def serialize_block(tag: str, body: str) -> str:
|
|
224
|
+
if tag == "blockquote":
|
|
225
|
+
return (
|
|
226
|
+
"\n\n"
|
|
227
|
+
+ "\n".join("> " + line if line else ">" for line in body.strip().splitlines())
|
|
228
|
+
+ "\n\n"
|
|
229
|
+
)
|
|
230
|
+
if tag == "br":
|
|
231
|
+
return " \n"
|
|
232
|
+
if tag == "hr":
|
|
233
|
+
return "\n\n---\n\n"
|
|
234
|
+
if tag == "dt":
|
|
235
|
+
return "\n\n" + wrap_inline(body.strip(), "**") + "\n"
|
|
236
|
+
if tag == "dd":
|
|
237
|
+
return "\n" + body.strip() + "\n\n"
|
|
238
|
+
if tag in BLOCKS:
|
|
239
|
+
return "\n\n" + body.strip() + "\n\n" if body.strip() else ""
|
|
240
|
+
return body
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def child_parts(node: Node, rendered: dict[Node, str]) -> list[str]:
|
|
244
|
+
parts = []
|
|
245
|
+
block_tags = BLOCKS | HEADINGS | {"ul", "ol", "pre", "table", "blockquote", "hr", "li"}
|
|
246
|
+
for index, child in enumerate(node.children):
|
|
247
|
+
if isinstance(child, Node):
|
|
248
|
+
parts.append(rendered[child])
|
|
249
|
+
continue
|
|
250
|
+
if child.isspace():
|
|
251
|
+
neighbors = (
|
|
252
|
+
node.children[max(0, index - 1) : index] + node.children[index + 1 : index + 2]
|
|
253
|
+
)
|
|
254
|
+
if any(isinstance(other, Node) and other.tag in block_tags for other in neighbors):
|
|
255
|
+
continue
|
|
256
|
+
parts.append(escape(SPACE.sub(" ", child)))
|
|
257
|
+
return parts
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render(selected: list[Node], base: str | None) -> str:
|
|
261
|
+
output = []
|
|
262
|
+
for root in selected:
|
|
263
|
+
rendered: dict[Node, str] = {}
|
|
264
|
+
for node in postorder(root):
|
|
265
|
+
parts = child_parts(node, rendered)
|
|
266
|
+
body = join_parts(parts)
|
|
267
|
+
rendered[node] = serialize(node, body, rendered, base)
|
|
268
|
+
output.append(rendered[root])
|
|
269
|
+
result = join_parts(output).strip()
|
|
270
|
+
return result + "\n" if result else ""
|
htomd/_selection.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Select content using cached evidence, then clean only the selected region."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ._models import Diagnostics
|
|
10
|
+
from ._tree import Node, elements, postorder, walk
|
|
11
|
+
|
|
12
|
+
INERT = frozenset(
|
|
13
|
+
[
|
|
14
|
+
"head",
|
|
15
|
+
"title",
|
|
16
|
+
"meta",
|
|
17
|
+
"link",
|
|
18
|
+
"base",
|
|
19
|
+
"script",
|
|
20
|
+
"style",
|
|
21
|
+
"template",
|
|
22
|
+
"noscript",
|
|
23
|
+
"iframe",
|
|
24
|
+
"object",
|
|
25
|
+
"embed",
|
|
26
|
+
"svg",
|
|
27
|
+
"canvas",
|
|
28
|
+
"nav",
|
|
29
|
+
"button",
|
|
30
|
+
"input",
|
|
31
|
+
"select",
|
|
32
|
+
"textarea",
|
|
33
|
+
"dialog",
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
NAV_ROLES = frozenset({"navigation", "banner", "contentinfo", "menu", "menubar", "dialog"})
|
|
37
|
+
STRUCTURES = frozenset({"ul", "ol", "dl", "table", "blockquote", "pre"})
|
|
38
|
+
CONTAINERS = frozenset({"article", "main", "section", "div", "body", "#document"})
|
|
39
|
+
EVIDENCE = frozenset({"p", "pre", "li", "dt", "dd", "td", "th", "blockquote"})
|
|
40
|
+
HEADINGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
|
|
41
|
+
NEGATIVE = frozenset(
|
|
42
|
+
[
|
|
43
|
+
"advertisement",
|
|
44
|
+
"ads",
|
|
45
|
+
"advert",
|
|
46
|
+
"promo",
|
|
47
|
+
"promotion",
|
|
48
|
+
"related",
|
|
49
|
+
"share",
|
|
50
|
+
"sharing",
|
|
51
|
+
"social",
|
|
52
|
+
"cookie",
|
|
53
|
+
"consent",
|
|
54
|
+
"newsletter",
|
|
55
|
+
"comments",
|
|
56
|
+
"comment",
|
|
57
|
+
"sidebar",
|
|
58
|
+
"breadcrumb",
|
|
59
|
+
"breadcrumbs",
|
|
60
|
+
"pagination",
|
|
61
|
+
"toolbar",
|
|
62
|
+
"footer",
|
|
63
|
+
"banner",
|
|
64
|
+
"dropdown",
|
|
65
|
+
"catlinks",
|
|
66
|
+
"menu",
|
|
67
|
+
"pager",
|
|
68
|
+
"teaser",
|
|
69
|
+
"toc",
|
|
70
|
+
"well",
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
POSITIVE = frozenset(
|
|
74
|
+
{"article", "content", "main", "post", "entry", "story", "text", "documentation"}
|
|
75
|
+
)
|
|
76
|
+
REFERENCES = frozenset({"footnotes", "references", "endnotes", "bibliography"})
|
|
77
|
+
PUNCTUATION = re.compile(r"[,.;:!?。,;:!?،؛]")
|
|
78
|
+
HIDDEN_STYLE = re.compile(
|
|
79
|
+
r"(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)",
|
|
80
|
+
re.I,
|
|
81
|
+
)
|
|
82
|
+
# Sibling recovery needs modest support, and can never escape its parent container.
|
|
83
|
+
SIBLING_SCORE_RATIO = 0.18
|
|
84
|
+
MAX_FALLBACK_BLOCKS = 256
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class Stats:
|
|
89
|
+
characters: int = 0
|
|
90
|
+
linked: int = 0
|
|
91
|
+
punctuation: int = 0
|
|
92
|
+
blocks: int = 0
|
|
93
|
+
headings: int = 0
|
|
94
|
+
code: int = 0
|
|
95
|
+
cells: int = 0
|
|
96
|
+
images: int = 0
|
|
97
|
+
controls: int = 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def density(self) -> float:
|
|
101
|
+
return self.linked / max(1, self.characters)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def hints(node: Node) -> set[str]:
|
|
105
|
+
value = node.attrs.get("class", "") + " " + node.attrs.get("id", "")
|
|
106
|
+
value = re.sub(r"([a-z])([A-Z])", r"\1 \2", value)
|
|
107
|
+
return set(re.findall(r"[a-z0-9]+", value.lower()))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def explicitly_hidden(node: Node) -> bool:
|
|
111
|
+
return (
|
|
112
|
+
"hidden" in node.attrs
|
|
113
|
+
or node.attrs.get("aria-hidden", "").lower() == "true"
|
|
114
|
+
or bool(HIDDEN_STYLE.search(node.attrs.get("style", "")))
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def visible_tree(root: Node) -> Node:
|
|
119
|
+
"""Copy visible content so metadata remains available on the original tree."""
|
|
120
|
+
result = Node(root.tag, root.attrs.copy())
|
|
121
|
+
stack = [(root, result, False)]
|
|
122
|
+
while stack:
|
|
123
|
+
original, target, local = stack.pop()
|
|
124
|
+
local = local or original.tag in {"article", "main"} or original.attrs.get("role") == "main"
|
|
125
|
+
for child in original.children:
|
|
126
|
+
if isinstance(child, str):
|
|
127
|
+
target.children.append(child)
|
|
128
|
+
continue
|
|
129
|
+
if excluded(child, local):
|
|
130
|
+
continue
|
|
131
|
+
copied = Node(child.tag, child.attrs.copy(), parent=target)
|
|
132
|
+
target.children.append(copied)
|
|
133
|
+
stack.append((child, copied, local))
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def excluded(node: Node, local: bool) -> bool:
|
|
138
|
+
if node.tag in INERT or explicitly_hidden(node):
|
|
139
|
+
return True
|
|
140
|
+
if node.tag == "a" and "headerlink" in hints(node):
|
|
141
|
+
return node.attrs.get("href", "").startswith("#")
|
|
142
|
+
role = node.attrs.get("role", "").lower()
|
|
143
|
+
if role in NAV_ROLES:
|
|
144
|
+
return True
|
|
145
|
+
return node.tag in {"header", "footer"} and not local
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def statistics(root: Node) -> dict[Node, Stats]:
|
|
149
|
+
result: dict[Node, Stats] = {}
|
|
150
|
+
for node in postorder(root):
|
|
151
|
+
stat = Stats()
|
|
152
|
+
for child in node.children:
|
|
153
|
+
if isinstance(child, str):
|
|
154
|
+
stat.characters += len(child.strip())
|
|
155
|
+
stat.punctuation += len(PUNCTUATION.findall(child))
|
|
156
|
+
else:
|
|
157
|
+
other = result[child]
|
|
158
|
+
for field in Stats.__slots__:
|
|
159
|
+
setattr(stat, field, getattr(stat, field) + getattr(other, field))
|
|
160
|
+
stat.blocks += int(node.tag in EVIDENCE and stat.characters > 0)
|
|
161
|
+
stat.headings += int(node.tag in HEADINGS and stat.characters > 0)
|
|
162
|
+
stat.code += int(node.tag == "pre")
|
|
163
|
+
stat.cells += int(node.tag in {"td", "th"})
|
|
164
|
+
stat.images += int(node.tag == "img" and bool(node.attrs.get("src")))
|
|
165
|
+
stat.controls += int(node.tag in {"form", "button", "input", "select", "textarea"})
|
|
166
|
+
if node.tag == "a":
|
|
167
|
+
stat.linked = stat.characters
|
|
168
|
+
result[node] = stat
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def conditional_clutter(node: Node, stat: Stats) -> bool:
|
|
173
|
+
if node.tag in {"#document", "html", "body", "main", "article"}:
|
|
174
|
+
return False
|
|
175
|
+
tokens = hints(node)
|
|
176
|
+
if tokens & REFERENCES or node.attrs.get("role") == "note":
|
|
177
|
+
return False
|
|
178
|
+
if not tokens & NEGATIVE or node.tag in EVIDENCE | HEADINGS:
|
|
179
|
+
return False
|
|
180
|
+
# Class hints alone never delete a node: require structural corroboration.
|
|
181
|
+
if stat.density > 0.35 or stat.controls:
|
|
182
|
+
return True
|
|
183
|
+
if "footer" in tokens and stat.density > 0.15:
|
|
184
|
+
return True
|
|
185
|
+
if node.tag == "aside" and not stat.code and not stat.cells:
|
|
186
|
+
return True
|
|
187
|
+
if stat.blocks == 0 and stat.characters < 180:
|
|
188
|
+
return True
|
|
189
|
+
if tokens & {"comments", "comment"}:
|
|
190
|
+
children = elements(node)
|
|
191
|
+
return sum(bool(hints(child) & {"comment", "reply"}) for child in children) >= 2
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def clean(root: Node) -> tuple[Node, int]:
|
|
196
|
+
stats = statistics(root)
|
|
197
|
+
removed = 0
|
|
198
|
+
for node in walk(root):
|
|
199
|
+
kept: list[Node | str] = []
|
|
200
|
+
for child in node.children:
|
|
201
|
+
if isinstance(child, Node) and conditional_clutter(child, stats[child]):
|
|
202
|
+
removed += 1
|
|
203
|
+
else:
|
|
204
|
+
kept.append(child)
|
|
205
|
+
node.children = kept
|
|
206
|
+
return root, removed
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def plausible(node: Node, stat: Stats, *, semantic: bool = False) -> bool:
|
|
210
|
+
if not stat.characters:
|
|
211
|
+
return bool(semantic and stat.images)
|
|
212
|
+
if stat.code or stat.cells:
|
|
213
|
+
return True
|
|
214
|
+
if node.tag == "p" and stat.characters > stat.linked:
|
|
215
|
+
return True
|
|
216
|
+
if stat.density >= 0.8:
|
|
217
|
+
return bool(semantic and stat.headings and stat.blocks)
|
|
218
|
+
return node.tag in CONTAINERS | EVIDENCE | HEADINGS | STRUCTURES or semantic
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def scores(root: Node, stats: dict[Node, Stats], *, relaxed: bool) -> dict[Node, float]:
|
|
222
|
+
result: dict[Node, float] = {}
|
|
223
|
+
for node in walk(root):
|
|
224
|
+
stat = stats[node]
|
|
225
|
+
if node.tag not in CONTAINERS or not plausible(node, stat):
|
|
226
|
+
continue
|
|
227
|
+
tokens = hints(node)
|
|
228
|
+
score = math.sqrt(stat.characters) + min(stat.blocks, 30) * 2
|
|
229
|
+
score += min(stat.punctuation, 40) * 0.25 + min(stat.code + stat.cells, 12) * 2
|
|
230
|
+
score += 8 * bool(tokens & POSITIVE)
|
|
231
|
+
if not relaxed:
|
|
232
|
+
score -= 18 * bool(tokens & NEGATIVE)
|
|
233
|
+
result[node] = score * (1 - stat.density) ** 2
|
|
234
|
+
# Leaf evidence flows to nearby ancestors, without repeatedly counting nested blocks.
|
|
235
|
+
for node in walk(root):
|
|
236
|
+
if node.tag not in EVIDENCE or any(child.tag in EVIDENCE for child in elements(node)):
|
|
237
|
+
continue
|
|
238
|
+
stat = stats[node]
|
|
239
|
+
support = (1 + min(stat.characters / 100, 3)) * (1 - stat.density)
|
|
240
|
+
ancestor = node.parent
|
|
241
|
+
for weight in (1.0, 0.5, 0.25):
|
|
242
|
+
if ancestor is None:
|
|
243
|
+
break
|
|
244
|
+
if ancestor in result:
|
|
245
|
+
result[ancestor] += support * weight
|
|
246
|
+
ancestor = ancestor.parent
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def semantic_candidate(
|
|
251
|
+
root: Node, stats: dict[Node, Stats], ranked: dict[Node, float]
|
|
252
|
+
) -> Node | None:
|
|
253
|
+
candidates = []
|
|
254
|
+
landmarks = []
|
|
255
|
+
for node in walk(root):
|
|
256
|
+
landmark = node.tag == "main" or node.attrs.get("role") == "main"
|
|
257
|
+
if not (landmark or node.tag == "article"):
|
|
258
|
+
continue
|
|
259
|
+
if not plausible(node, stats[node], semantic=True):
|
|
260
|
+
continue
|
|
261
|
+
if conditional_clutter(node, stats[node]):
|
|
262
|
+
continue
|
|
263
|
+
if "teaser" in hints(node) and stats[node].density > 0.1:
|
|
264
|
+
continue
|
|
265
|
+
candidates.append(node)
|
|
266
|
+
if landmark:
|
|
267
|
+
landmarks.append(node)
|
|
268
|
+
candidates = landmarks or candidates
|
|
269
|
+
if not candidates:
|
|
270
|
+
return None
|
|
271
|
+
return max(candidates, key=lambda node: ranked.get(node, 0))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def recover_siblings(
|
|
275
|
+
winner: Node, stats: dict[Node, Stats], ranked: dict[Node, float]
|
|
276
|
+
) -> list[Node]:
|
|
277
|
+
parent = winner.parent
|
|
278
|
+
if parent is None:
|
|
279
|
+
return [winner]
|
|
280
|
+
threshold = max(5, ranked.get(winner, 0) * SIBLING_SCORE_RATIO)
|
|
281
|
+
selected = []
|
|
282
|
+
for sibling in elements(parent):
|
|
283
|
+
stat = stats[sibling]
|
|
284
|
+
if conditional_clutter(sibling, stat):
|
|
285
|
+
continue
|
|
286
|
+
supported = ranked.get(sibling, 0) >= threshold and stat.density < 0.5
|
|
287
|
+
heading = sibling.tag in HEADINGS and stat.density < 0.5
|
|
288
|
+
paragraph = sibling.tag == "p" and stat.density < 0.25 and bool(stat.characters)
|
|
289
|
+
if sibling is winner or supported or heading or paragraph:
|
|
290
|
+
selected.append(sibling)
|
|
291
|
+
return selected
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def fallback(root: Node, stats: dict[Node, Stats]) -> list[Node]:
|
|
295
|
+
selected: list[Node] = []
|
|
296
|
+
stack = [root]
|
|
297
|
+
while stack and len(selected) < MAX_FALLBACK_BLOCKS:
|
|
298
|
+
node = stack.pop()
|
|
299
|
+
stat = stats[node]
|
|
300
|
+
if conditional_clutter(node, stat):
|
|
301
|
+
continue
|
|
302
|
+
if (
|
|
303
|
+
node.tag in EVIDENCE | HEADINGS | STRUCTURES
|
|
304
|
+
and plausible(node, stat)
|
|
305
|
+
or not elements(node)
|
|
306
|
+
and stat.characters
|
|
307
|
+
and stat.density < 0.5
|
|
308
|
+
):
|
|
309
|
+
selected.append(node)
|
|
310
|
+
else:
|
|
311
|
+
stack.extend(reversed(elements(node)))
|
|
312
|
+
return selected
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def select(root: Node) -> tuple[list[Node], Diagnostics]:
|
|
316
|
+
visible, removed = clean(visible_tree(root))
|
|
317
|
+
stats = statistics(visible)
|
|
318
|
+
ranked = scores(visible, stats, relaxed=False)
|
|
319
|
+
notes = [f"Removed {removed} conditionally identified clutter blocks."] if removed else []
|
|
320
|
+
winner = semantic_candidate(visible, stats, ranked)
|
|
321
|
+
if winner is not None:
|
|
322
|
+
return [winner], Diagnostics("semantic", tuple(notes))
|
|
323
|
+
candidates = {
|
|
324
|
+
node: score for node, score in ranked.items() if node.tag not in {"body", "#document"}
|
|
325
|
+
}
|
|
326
|
+
if not candidates or max(candidates.values()) < 5:
|
|
327
|
+
notes.append("Retried selection once with relaxed class penalties.")
|
|
328
|
+
ranked = scores(visible, stats, relaxed=True)
|
|
329
|
+
candidates = {
|
|
330
|
+
node: score for node, score in ranked.items() if node.tag not in {"body", "#document"}
|
|
331
|
+
}
|
|
332
|
+
if candidates and max(candidates.values()) >= 5:
|
|
333
|
+
winner = max(candidates, key=lambda node: candidates[node])
|
|
334
|
+
return recover_siblings(winner, stats, ranked), Diagnostics("scored", tuple(notes))
|
|
335
|
+
recovered = fallback(visible, stats)
|
|
336
|
+
notes.append(
|
|
337
|
+
"Recovered plausible blocks." if recovered else "No relevant visible content found."
|
|
338
|
+
)
|
|
339
|
+
return recovered, Diagnostics("fallback" if recovered else "none", tuple(notes))
|
htomd/_tree.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Ordered, private HTML tree and stack-safe traversals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
SPACE = re.compile(r"\s+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(eq=False, slots=True)
|
|
13
|
+
class Node:
|
|
14
|
+
tag: str
|
|
15
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
16
|
+
children: list[Node | str] = field(default_factory=list)
|
|
17
|
+
parent: Node | None = field(default=None, repr=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def walk(root: Node) -> Iterator[Node]:
|
|
21
|
+
stack = [root]
|
|
22
|
+
while stack:
|
|
23
|
+
node = stack.pop()
|
|
24
|
+
yield node
|
|
25
|
+
stack.extend(child for child in reversed(node.children) if isinstance(child, Node))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def postorder(root: Node) -> Iterator[Node]:
|
|
29
|
+
stack = [(root, False)]
|
|
30
|
+
while stack:
|
|
31
|
+
node, visited = stack.pop()
|
|
32
|
+
if visited:
|
|
33
|
+
yield node
|
|
34
|
+
continue
|
|
35
|
+
stack.append((node, True))
|
|
36
|
+
stack.extend((child, False) for child in reversed(node.children) if isinstance(child, Node))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def text_content(root: Node, *, normalize: bool = True) -> str:
|
|
40
|
+
pieces: list[str] = []
|
|
41
|
+
stack: list[Node | str] = [root]
|
|
42
|
+
while stack:
|
|
43
|
+
item = stack.pop()
|
|
44
|
+
if isinstance(item, str):
|
|
45
|
+
pieces.append(item)
|
|
46
|
+
else:
|
|
47
|
+
stack.extend(reversed(item.children))
|
|
48
|
+
value = "".join(pieces)
|
|
49
|
+
return SPACE.sub(" ", value).strip() if normalize else value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def elements(node: Node) -> list[Node]:
|
|
53
|
+
return [child for child in node.children if isinstance(child, Node)]
|
htomd/_urls.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Resolve references without fetching; reject active or unknown schemes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import quote, urljoin, urlsplit
|
|
5
|
+
|
|
6
|
+
from ._tree import Node, walk
|
|
7
|
+
|
|
8
|
+
SAFE_SCHEMES = frozenset({"", "http", "https", "mailto", "tel", "ftp"})
|
|
9
|
+
CONTROL = re.compile(r"[\x00-\x20\x7f]+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_url(value: str, base: str | None = None) -> str | None:
|
|
13
|
+
value = value.strip()
|
|
14
|
+
# Browsers ignore ASCII control characters in schemes. Check the normalized
|
|
15
|
+
# spelling so an entity-encoded newline cannot disguise javascript:.
|
|
16
|
+
checked = CONTROL.sub("", value)
|
|
17
|
+
try:
|
|
18
|
+
if urlsplit(checked).scheme.lower() not in SAFE_SCHEMES:
|
|
19
|
+
return None
|
|
20
|
+
resolved = urljoin(base, value) if base else value
|
|
21
|
+
if urlsplit(CONTROL.sub("", resolved)).scheme.lower() not in SAFE_SCHEMES:
|
|
22
|
+
return None
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
25
|
+
return resolved
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def document_base(root: Node, url: str | None) -> str | None:
|
|
29
|
+
source = safe_url(url) if url else None
|
|
30
|
+
for node in walk(root):
|
|
31
|
+
if node.tag != "base" or "href" not in node.attrs:
|
|
32
|
+
continue
|
|
33
|
+
candidate = safe_url(node.attrs["href"], source)
|
|
34
|
+
if candidate:
|
|
35
|
+
parts = urlsplit(candidate)
|
|
36
|
+
if parts.scheme in {"http", "https"} and parts.netloc:
|
|
37
|
+
return candidate
|
|
38
|
+
return source
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def destination(value: str) -> str:
|
|
42
|
+
# Parentheses and whitespace must not terminate a Markdown destination.
|
|
43
|
+
return quote(value, safe="/:?#@!$&'*+,;=%[]~_-.")
|
htomd/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: htomd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Focused Markdown and metadata from messy HTML, in pure Python
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/jamiedavenport/htomd
|
|
7
|
+
Project-URL: Issues, https://github.com/jamiedavenport/htomd/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jamiedavenport/htomd/blob/main/CHANGELOG.md
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# htomd
|
|
21
|
+
|
|
22
|
+
Extract Markdown and metadata from HTML. Pure Python 3.12+, with no runtime
|
|
23
|
+
dependencies or network access.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
python -m pip install htomd
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For the command line, install in an isolated tool environment:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv tool install htomd
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Python API
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import htomd
|
|
41
|
+
|
|
42
|
+
html = "<article><h1>Hello</h1><p>Readable text.</p></article>"
|
|
43
|
+
markdown = htomd.convert(html)
|
|
44
|
+
document = htomd.extract(html, url="https://example.com/article")
|
|
45
|
+
print(document.markdown, document.metadata.title, document.diagnostics)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pass decoded HTML strings. `url` resolves relative references. `extract()` returns
|
|
49
|
+
immutable results; missing metadata is `None`. Empty content yields empty Markdown;
|
|
50
|
+
invalid argument types raise `TypeError`.
|
|
51
|
+
|
|
52
|
+
Best suited to articles and documentation. Extraction can miss content or retain
|
|
53
|
+
clutter. JavaScript, browser layout, math, and SVG are unsupported. Simple tables
|
|
54
|
+
use GFM; complex tables become row/cell text.
|
|
55
|
+
|
|
56
|
+
## Command line
|
|
57
|
+
|
|
58
|
+
Installing the package also installs the `htomd` command. To install a locally
|
|
59
|
+
built wheel in a virtual environment:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
python -m venv .venv
|
|
63
|
+
source .venv/bin/activate
|
|
64
|
+
python -m pip install dist/htomd-0.1.0-py3-none-any.whl
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
In Windows PowerShell, activate with `.venv\Scripts\Activate.ps1` instead.
|
|
68
|
+
See [Contributing](https://github.com/jamiedavenport/htomd/blob/main/CONTRIBUTING.md#releases)
|
|
69
|
+
for the build commands.
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
curl -s https://example.com/article | htomd convert --url https://example.com/article
|
|
73
|
+
cat page.html | htomd convert > page.md
|
|
74
|
+
cat page.html | htomd extract > page.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Both commands read stdin to EOF and accept no file arguments. Input must be UTF-8
|
|
78
|
+
(an optional UTF-8 BOM is accepted); output is UTF-8. `--url` supplies source
|
|
79
|
+
context for relative references and metadata; it never fetches the URL.
|
|
80
|
+
|
|
81
|
+
`convert` writes Markdown unchanged. `extract` writes indented JSON containing
|
|
82
|
+
`markdown`, `metadata`, and `diagnostics`, matching the Python result structure.
|
|
83
|
+
Missing metadata is `null`; diagnostic notes are an array. Empty or malformed
|
|
84
|
+
HTML receives the same best-effort handling as the library.
|
|
85
|
+
|
|
86
|
+
Run `htomd`, `htomd help`, or `htomd --help` for usage and pipeline examples.
|
|
87
|
+
Use `htomd help convert` or `htomd convert --help` for command-specific help
|
|
88
|
+
(likewise for `extract`). `htomd version` or `htomd --version` prints the installed
|
|
89
|
+
package version. Help and version commands exit successfully without reading stdin.
|
|
90
|
+
|
|
91
|
+
`python -m htomd` supports the same commands. Successful conversion exits with code 0, I/O and
|
|
92
|
+
decoding failures with code 1, and usage errors with code 2. Errors go to stderr.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
mise trust
|
|
98
|
+
mise install
|
|
99
|
+
mise run setup
|
|
100
|
+
mise exec -- uv run --locked pytest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`mise run check` runs all checks. See
|
|
104
|
+
[CONTRIBUTING.md](https://github.com/jamiedavenport/htomd/blob/main/CONTRIBUTING.md)
|
|
105
|
+
for hooks and releases.
|
|
106
|
+
|
|
107
|
+
MIT license, copyright 2026 JXD Ltd.
|
|
108
|
+
[Fixtures](https://github.com/jamiedavenport/htomd/blob/main/tests/fixtures/real/README.md)
|
|
109
|
+
have separate licenses.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
htomd/__init__.py,sha256=5jdmI8vbSl1GzcK7AINC75sUI_AGJ_gloJv96DRnu8s,1478
|
|
2
|
+
htomd/__main__.py,sha256=8NSrSYJ5OvYnJlgKGVngL5bpg8mmlguEyu6-YGRikuE,116
|
|
3
|
+
htomd/_cli.py,sha256=WlTTrmFGCEVjYrfF8FiryqhmzeejcNOCMom27vcAM00,3411
|
|
4
|
+
htomd/_metadata.py,sha256=v8MSFdIsrU2QHcPQwSFQ8ynUKNK7UsltJhd3tOlfyUs,4868
|
|
5
|
+
htomd/_models.py,sha256=wRcOs3Wldeh_yxJX5zqSQb28ORe6ZXg6ylr5tdofoH0,692
|
|
6
|
+
htomd/_parser.py,sha256=GSroMryc-lELfoULeUFbtMdjzPhI1cVWLXkV9MGyWY4,4119
|
|
7
|
+
htomd/_render.py,sha256=Q7OS7M2L9tU9BtPgBpnnaE0yxleat-by_GJ2U4BGluw,9103
|
|
8
|
+
htomd/_selection.py,sha256=xS2k5fHGN2hYWYUH5B9y0ZDFcgHKpCRqj_bNlknhuzI,11634
|
|
9
|
+
htomd/_tree.py,sha256=AnhOyo3YGE-CgU-WLcDigoOYyAwkwv9_N5pFXjXmZSQ,1504
|
|
10
|
+
htomd/_urls.py,sha256=gpaEms-Svr1mr3hzD_9zeWTzCmpGZbITM9ZDwrybG7o,1515
|
|
11
|
+
htomd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
htomd-0.1.0.dist-info/licenses/LICENSE,sha256=VHLXTDfL3E6-kxgxwOIVcf7-8NvDOP3x-Kk6kanwWF4,1064
|
|
13
|
+
htomd-0.1.0.dist-info/METADATA,sha256=eVXT1zy6U7OuPVA7m3EP2mSujykAPc54DUYu2BI_NHQ,3782
|
|
14
|
+
htomd-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
htomd-0.1.0.dist-info/entry_points.txt,sha256=v29vVSHQxcHtaJcFMD_yFeWIrXmNoxNdiXoJgcnpqJM,42
|
|
16
|
+
htomd-0.1.0.dist-info/top_level.txt,sha256=EzaqcXzCQSBq-SYUbgIvrK1-c5wRXFNDrhmGcyFctNY,6
|
|
17
|
+
htomd-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JXD Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
htomd
|