|
1 | | -import re |
2 | | -from bs4 import BeautifulSoup, NavigableString, Tag, PageElement, Comment |
| 1 | +from .html_reader_mode import HTMLReaderMode |
3 | 2 |
|
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