dom_content_extraction/
tree.rs1use ego_tree::NodeId;
2use scraper::{Html, Selector};
3use std::sync::LazyLock;
4
5pub static BODY_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
7 Selector::parse("body").expect("Can't be (parsing body selector)")
8});
9
10#[derive(Debug, Clone, Default)]
11pub struct NodeMetrics {
12 pub char_count: u32,
13 pub tag_count: u32,
14 pub link_char_count: u32,
15 pub link_tag_count: u32,
16}
17
18pub trait TreeBuilder {
19 fn build_metrics(&self, node_id: NodeId) -> NodeMetrics;
20 fn get_children(&self, node_id: NodeId) -> Vec<NodeId>;
21 fn get_parent(&self, node_id: NodeId) -> Option<NodeId>;
22}
23
24pub struct HtmlTreeBuilder<'a> {
25 document: &'a Html,
26}
27
28impl<'a> HtmlTreeBuilder<'a> {
29 pub fn new(document: &'a Html) -> Self {
30 Self { document }
31 }
32}
33
34impl TreeBuilder for HtmlTreeBuilder<'_> {
35 fn build_metrics(&self, node_id: NodeId) -> NodeMetrics {
36 let node = self.document.tree.get(node_id).unwrap();
37
38 let mut metrics = NodeMetrics {
39 char_count: 0,
40 tag_count: 0,
41 link_char_count: 0,
42 link_tag_count: 0,
43 };
44
45 match node.value() {
46 scraper::Node::Text(text) => {
47 let clean_text = text.trim();
50 if !crate::utils::is_non_content_text(clean_text) {
51 metrics.char_count =
52 crate::unicode::count_graphemes(clean_text);
53 }
54 }
55 scraper::Node::Element(elem) => {
56 metrics.tag_count = 1;
57 if elem.name() == "a"
58 || elem.name() == "button"
59 || elem.name() == "select"
60 {
61 metrics.link_tag_count = 1;
62 }
63 }
64 _ => {}
65 }
66
67 metrics
68 }
69
70 fn get_children(&self, node_id: NodeId) -> Vec<NodeId> {
71 self.document
72 .tree
73 .get(node_id)
74 .map(|node| {
75 node.children()
76 .filter(|child| match child.value() {
77 scraper::Node::Element(elem) => {
78 !crate::utils::should_skip_element(elem)
79 }
80 scraper::Node::Text(text) => {
81 !crate::utils::is_non_content_text(text)
82 }
83 scraper::Node::Comment(_) | scraper::Node::Document => {
84 false
85 }
86 _ => true,
87 })
88 .map(|child| child.id())
89 .collect()
90 })
91 .unwrap_or_default()
92 }
93
94 fn get_parent(&self, node_id: NodeId) -> Option<NodeId> {
95 self.document
96 .tree
97 .get(node_id)
98 .and_then(|node| node.parent())
99 .map(|parent| parent.id())
100 }
101}
102
103impl NodeMetrics {
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub fn combine(&mut self, other: &NodeMetrics) {
110 self.char_count += other.char_count;
111 self.tag_count += other.tag_count;
112 self.link_char_count += other.link_char_count;
113 self.link_tag_count += other.link_tag_count;
114 }
115
116 pub fn calculate_simple_density(&self) -> f32 {
118 if self.tag_count == 0 {
119 0.0
120 } else {
121 self.char_count as f32 / self.tag_count as f32
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use scraper::Html;
130
131 const TEST_HTML: &str = r#"
132 <html>
133 <body>
134 <div class="content">
135 Some text here
136 <a href="\#">A link</a>
137 <p>More content</p>
138 <button>Click me</button>
139 <script>console.log('skip');</script>
140 <style>.skip{}</style>
141 </div>
142 <div class="sidebar">
143 <select>
144 <option>Option 1</option>
145 </select>
146 </div>
147 </body>
148 </html>
149 "#;
150
151 #[test]
152 fn test_body_selector_initialization() {
153 let _ = &*BODY_SELECTOR;
155 }
156
157 #[test]
158 fn test_node_metrics() {
159 let document = Html::parse_document(TEST_HTML);
160 let builder = HtmlTreeBuilder::new(&document);
161
162 let content_div = document
164 .select(&scraper::Selector::parse("div.content").unwrap())
165 .next()
166 .unwrap();
167
168 let metrics = builder.build_metrics(content_div.id());
169 assert_eq!(metrics.char_count, 0); assert_eq!(metrics.tag_count, 1); assert_eq!(metrics.link_tag_count, 0); }
174
175 #[test]
176 fn test_get_children_filters() {
177 let document = Html::parse_document(TEST_HTML);
178 let builder = HtmlTreeBuilder::new(&document);
179
180 let body = document
181 .select(&scraper::Selector::parse("body").unwrap())
182 .next()
183 .unwrap();
184
185 let children = builder.get_children(body.id());
186
187 assert_eq!(children.len(), 2);
189 }
190
191 #[test]
192 fn test_link_metrics() {
193 let document = Html::parse_document(TEST_HTML);
194 let builder = HtmlTreeBuilder::new(&document);
195
196 let link = document
198 .select(&scraper::Selector::parse("a").unwrap())
199 .next()
200 .unwrap();
201
202 let metrics = builder.build_metrics(link.id());
203 assert_eq!(metrics.link_tag_count, 1);
204
205 let button = document
207 .select(&scraper::Selector::parse("button").unwrap())
208 .next()
209 .unwrap();
210
211 let metrics = builder.build_metrics(button.id());
212 assert_eq!(metrics.link_tag_count, 1);
213
214 let select = document
216 .select(&scraper::Selector::parse("select").unwrap())
217 .next()
218 .unwrap();
219
220 let metrics = builder.build_metrics(select.id());
221 assert_eq!(metrics.link_tag_count, 1);
222 }
223
224 #[test]
225 fn test_text_metrics() {
226 let document = Html::parse_document(TEST_HTML);
227 let builder = HtmlTreeBuilder::new(&document);
228
229 let text_node = document
231 .select(&scraper::Selector::parse(".content").unwrap())
232 .next()
233 .unwrap()
234 .first_child()
235 .unwrap();
236
237 let metrics = builder.build_metrics(text_node.id());
238 assert_eq!(metrics.char_count, 14); assert_eq!(metrics.tag_count, 0);
240 assert_eq!(metrics.link_tag_count, 0);
241 }
242
243 #[test]
244 fn test_get_parent() {
245 let document = Html::parse_document(TEST_HTML);
246 let builder = HtmlTreeBuilder::new(&document);
247
248 let content_div = document
249 .select(&scraper::Selector::parse("div.content").unwrap())
250 .next()
251 .unwrap();
252
253 let parent = builder.get_parent(content_div.id());
254 assert!(parent.is_some());
255
256 let body = document
258 .select(&scraper::Selector::parse("body").unwrap())
259 .next()
260 .unwrap();
261
262 assert_eq!(parent.unwrap(), body.id());
263 }
264}