Skip to content

Commit 3202185

Browse files
committed
Use python 3.9 type hint syntax
1 parent c017fd1 commit 3202185

2 files changed

Lines changed: 286 additions & 281 deletions

File tree

src/html_reader_mode/__init__.py

Lines changed: 2 additions & 281 deletions
Original file line numberDiff line numberDiff line change
@@ -1,282 +1,3 @@
1-
import re
2-
from bs4 import BeautifulSoup, NavigableString, Tag, PageElement, Comment
1+
from .html_reader_mode import HTMLReaderMode
32

4-
5-
class TextBlock:
6-
def __init__(
7-
self,
8-
text_builder: list[str],
9-
num_words: int,
10-
num_linked_words: int,
11-
tag_level: int,
12-
tag_name: str = "p",
13-
):
14-
self.text: str = "".join(text_builder).strip()
15-
self.num_words: int = num_words
16-
self.num_linked_words: int = num_linked_words
17-
self.link_density: float = (
18-
num_linked_words / num_words if num_words > 0 else 0.0
19-
)
20-
self.tag_level: int = tag_level
21-
self.tag_name: str = tag_name
22-
self.is_content: bool = False
23-
self.labels: set[str] = set()
24-
25-
def merge_next(self, other: "TextBlock") -> None:
26-
self.text += "\n" + other.text
27-
self.num_words += other.num_words
28-
self.num_linked_words += other.num_linked_words
29-
if self.num_words > 0:
30-
self.link_density = self.num_linked_words / self.num_words
31-
else:
32-
self.link_density = 0.0
33-
self.is_content = self.is_content or other.is_content
34-
self.labels.update(other.labels)
35-
self.tag_level = min(self.tag_level, other.tag_level)
36-
37-
if self.tag_name != other.tag_name:
38-
self.tag_name = "p"
39-
40-
41-
class HTMLReaderMode:
42-
DEFAULT_BLOCK_TAGS: set[str] = {
43-
"div",
44-
"p",
45-
"h1",
46-
"h2",
47-
"h3",
48-
"h4",
49-
"h5",
50-
"h6",
51-
"li",
52-
"blockquote",
53-
"pre",
54-
"header",
55-
"footer",
56-
"section",
57-
"article",
58-
"aside",
59-
}
60-
DEFAULT_SCRIPT_TAGS: set[str] = {"script", "style", "noscript", "iframe", "svg"}
61-
DEFAULT_TERMINATING_KEYWORDS: list[str] = [
62-
"comments",
63-
"share this",
64-
"related articles",
65-
"subscribe",
66-
"topics",
67-
"newsletter",
68-
"related",
69-
"read more",
70-
"about the author",
71-
"no newsletters selected",
72-
]
73-
DEFAULT_CUTOFF_KEYWORDS: list[str] = [
74-
"comments",
75-
"related articles",
76-
"topics",
77-
"newsletter",
78-
"related",
79-
"about the author",
80-
]
81-
82-
def __init__(
83-
self,
84-
minimum_cutoff_threshold: int = 100,
85-
minimum_block_words: int = 16,
86-
maximum_preceding_block_link_density: float = 0.5,
87-
maximum_block_link_density: float = 0.33,
88-
block_tags: set[str] | None = None,
89-
script_tags: set[str] | None = None,
90-
terminating_keywords: list[str] | None = None,
91-
cutoff_keywords: list[str] | None = None,
92-
):
93-
"""Initialize the HTMLReaderMode with the given parameters
94-
95-
Args:
96-
minimum_cutoff_threshold (int): Minimum number of words to trigger cutoff regex matching
97-
minimum_block_words (int): Minimum number of words in a content block
98-
maximum_preceding_block_link_density (float): Maximum link density of the preceding block
99-
maximum_block_link_density (float): Maximum link density of the block
100-
block_tags (set[str] | None): set of block HTML tags
101-
script_tags (set[str] | None): set of script HTML tags
102-
terminating_keywords (list[str] | None): list of terminating keywords
103-
cutoff_keywords (list[str] | None): list of cutoff keywords
104-
"""
105-
self.minimum_cutoff_threshold = minimum_cutoff_threshold
106-
self.minimum_block_words = minimum_block_words
107-
self.maximum_preceding_block_link_density = maximum_preceding_block_link_density
108-
self.maximum_block_link_density = maximum_block_link_density
109-
self.block_tags = (
110-
block_tags if block_tags is not None else self.DEFAULT_BLOCK_TAGS
111-
)
112-
self.script_tags = (
113-
script_tags if script_tags is not None else self.DEFAULT_SCRIPT_TAGS
114-
)
115-
self.terminating_keywords = (
116-
terminating_keywords
117-
if terminating_keywords is not None
118-
else self.DEFAULT_TERMINATING_KEYWORDS
119-
)
120-
self.cutoff_keywords = (
121-
cutoff_keywords
122-
if cutoff_keywords is not None
123-
else self.DEFAULT_CUTOFF_KEYWORDS
124-
)
125-
126-
def sanitize(self, html: str) -> list[dict[str, str]]:
127-
if not html:
128-
return []
129-
soup = BeautifulSoup(html, "html.parser")
130-
131-
for script in soup(self.script_tags):
132-
script.decompose()
133-
134-
root = soup.body if soup.body else soup
135-
blocks: list[TextBlock] = self._linearize_dom(root)
136-
self._classify_blocks(blocks)
137-
138-
return [{"tag": b.tag_name, "content": b.text} for b in blocks if b.is_content]
139-
140-
def _linearize_dom(self, root: PageElement) -> list[TextBlock]:
141-
blocks: list[TextBlock] = []
142-
143-
current_text: list[str] = []
144-
current_num_words: int = 0
145-
current_num_linked_words: int = 0
146-
147-
def flush_block(tag_level: int, tag_name: str) -> None:
148-
nonlocal current_text, current_num_words, current_num_linked_words
149-
if current_text:
150-
text_content = "".join(current_text).strip()
151-
if text_content:
152-
blocks.append(
153-
TextBlock(
154-
current_text,
155-
current_num_words,
156-
current_num_linked_words,
157-
tag_level,
158-
tag_name,
159-
)
160-
)
161-
current_text = []
162-
current_num_words = 0
163-
current_num_linked_words = 0
164-
165-
def count_words(text: str) -> int:
166-
return len(text.split())
167-
168-
def traverse(
169-
node: PageElement, depth: int, in_link: bool, container_tag: str
170-
) -> None:
171-
nonlocal current_num_words, current_num_linked_words
172-
173-
if isinstance(node, Comment):
174-
return
175-
176-
if isinstance(node, NavigableString):
177-
text = str(node)
178-
if not text.strip():
179-
return
180-
current_text.append(text)
181-
words = count_words(text)
182-
current_num_words += words
183-
if in_link:
184-
current_num_linked_words += words
185-
elif isinstance(node, Tag):
186-
is_block = node.name in self.block_tags
187-
is_link = node.name == "a"
188-
189-
next_container_tag = node.name if is_block else container_tag
190-
191-
if is_block:
192-
flush_block(depth, container_tag)
193-
194-
for child in node.children:
195-
traverse(child, depth + 1, in_link or is_link, next_container_tag)
196-
197-
if is_block:
198-
flush_block(depth, next_container_tag)
199-
200-
traverse(root, 0, False, "p")
201-
flush_block(0, "p")
202-
return blocks
203-
204-
def _classify_blocks(self, blocks: list[TextBlock]) -> None:
205-
if not blocks:
206-
return
207-
terminating_pattern = f"^({'|'.join(self.terminating_keywords)})$"
208-
cutoff_pattern = f"^({'|'.join(self.cutoff_keywords)})$"
209-
terminating_regex = re.compile(terminating_pattern, re.IGNORECASE)
210-
cutoff_regex = re.compile(cutoff_pattern, re.IGNORECASE)
211-
212-
cutoff = False
213-
content_words_so_far = 0
214-
215-
for i in range(len(blocks)):
216-
prev: TextBlock | None = blocks[i - 1] if i > 0 else None
217-
curr: TextBlock = blocks[i]
218-
next_block: TextBlock | None = (
219-
blocks[i + 1] if i < len(blocks) - 1 else None
220-
)
221-
222-
if cutoff:
223-
curr.is_content = False
224-
curr.labels.add("STRICTLY_NOT_CONTENT")
225-
continue
226-
227-
if (
228-
terminating_regex.search(curr.text)
229-
and curr.num_words < self.minimum_block_words
230-
):
231-
curr.labels.add("STRICTLY_NOT_CONTENT")
232-
if (
233-
cutoff_regex.search(curr.text)
234-
and content_words_so_far > self.minimum_cutoff_threshold
235-
):
236-
cutoff = True
237-
curr.is_content = False
238-
continue
239-
240-
if "STRICTLY_NOT_CONTENT" in curr.labels:
241-
curr.is_content = False
242-
continue
243-
244-
is_content = False
245-
if curr.link_density <= self.maximum_block_link_density:
246-
if (
247-
prev is None
248-
or prev.link_density <= self.maximum_preceding_block_link_density
249-
):
250-
if curr.num_words <= self.minimum_block_words:
251-
if (
252-
next_block is None
253-
or next_block.num_words <= self.minimum_block_words
254-
):
255-
if (
256-
prev is None
257-
or prev.num_words <= self.minimum_block_words
258-
):
259-
is_content = False
260-
else:
261-
is_content = True
262-
else:
263-
is_content = True
264-
else:
265-
is_content = True
266-
else:
267-
if curr.num_words <= self.minimum_block_words:
268-
if (
269-
next_block is None
270-
or next_block.num_words <= self.minimum_block_words
271-
):
272-
is_content = False
273-
else:
274-
is_content = True
275-
else:
276-
is_content = True
277-
else:
278-
is_content = False
279-
280-
curr.is_content = is_content
281-
if is_content:
282-
content_words_so_far += curr.num_words
3+
__all__ = ["HTMLReaderMode"]

0 commit comments

Comments
 (0)