libnetrunner/parser/html/
mod.rs1use ego_tree::NodeRef;
2use html5ever::{local_name, namespace_url, ns, QualName};
3use std::collections::HashSet;
4use url::Url;
5
6mod element_node;
7mod html_node;
8use crate::parser::ParseResult;
9use element_node::Node;
10use html_node::Html;
11
12pub const DEFAULT_DESC_LENGTH: usize = 256;
13
14fn normalize_href(url: &str, href: &str) -> Option<String> {
15 if let Ok(url) = Url::parse(url) {
17 if href.starts_with("//") {
18 if let Ok(url) = Url::parse(&format!("{}:{}", "https", href)) {
20 return Some(url.to_string());
21 }
22 } else if href.starts_with("http://") || href.starts_with("https://") {
23 if let Ok(url) = Url::parse(href) {
25 let mut url = url;
26 if url.scheme() == "http" {
27 url.set_scheme("https").expect("Unable to set HTTPS scheme");
28 }
29 return Some(url.to_string());
30 }
31 } else {
32 if let Ok(url) = url.join(href) {
34 return Some(url.to_string());
35 }
36 }
37 }
38
39 log::debug!("Unable to normalize href: {} - {}", url.to_string(), href);
40 None
41}
42
43fn filter_p_nodes(root: &NodeRef<Node>, p_list: &mut Vec<String>) {
45 for child in root.children() {
46 let node = child.value();
47 if node.is_element() {
48 let element = node.as_element().unwrap();
49 if element.name().eq_ignore_ascii_case("p") {
50 let mut p_content = String::from("");
51 let mut links = HashSet::new();
52 filter_text_nodes(&child, &mut p_content, &mut links);
53
54 if !p_content.is_empty() {
55 p_list.push(p_content);
56 }
57 }
58 }
59
60 if child.has_children() {
61 filter_p_nodes(&child, p_list);
62 }
63 }
64}
65
66fn filter_text_nodes(root: &NodeRef<Node>, doc: &mut String, links: &mut HashSet<String>) {
68 let ignore_list: HashSet<String> = HashSet::from([
71 "head".into(),
72 "sup".into(),
73 "header".into(),
75 "footer".into(),
76 "nav".into(),
77 "label".into(),
79 "textarea".into(),
80 "input".into(),
81 "script".into(),
83 "noscript".into(),
84 "style".into(),
85 ]);
86
87 let href_key = QualName::new(None, ns!(), local_name!("href"));
88 let role_key = QualName::new(None, ns!(), local_name!("role"));
89 let rel_key = QualName::new(None, ns!(), local_name!("rel"));
90
91 let mut noindex_skip = false;
92
93 for child in root.children() {
94 if noindex_skip {
95 continue;
96 }
97
98 let node = child.value();
99 if node.is_comment() {
102 if let Some(comment) = node.as_comment() {
103 if comment.contains("htdig_noindex") {
104 noindex_skip = true;
105 } else if comment.contains("/htdig_noindex") {
106 noindex_skip = false;
107 }
108 }
109 } else if node.is_text() {
110 doc.push_str(node.as_text().unwrap());
111 } else if node.is_element() {
112 let element = node.as_element().unwrap();
114 if ignore_list.contains(&element.name()) {
115 continue;
116 }
117
118 if element.attrs.contains_key(&role_key)
121 && (element.attrs.get(&role_key).unwrap().to_string() == *"navigation"
122 || element.attrs.get(&role_key).unwrap().to_string() == *"contentinfo"
123 || element.attrs.get(&role_key).unwrap().to_string() == *"button")
124 {
125 continue;
126 }
127
128 if element.name() == "a" && element.attrs.contains_key(&href_key) {
130 let href = element.attrs.get(&href_key).unwrap().to_string();
131 let rel = if let Some(rel) = element.attrs.get(&rel_key) {
132 rel.to_string().to_lowercase()
133 } else {
134 "follow".to_string()
135 };
136
137 if !href.starts_with('#')
139 && rel != "nofollow" && rel != "external"
141 {
142 links.insert(href.to_string());
143 }
144 } else if element.name() == "br" && !doc.ends_with(' ') {
145 doc.push(' ');
146 }
147
148 if child.has_children() {
149 filter_text_nodes(&child, doc, links);
150 if !doc.ends_with(' ') {
152 doc.push(' ');
153 }
154 }
155 }
156 }
157}
158
159pub fn process_canonical_url(url: &str, doc: &str) -> String {
161 let parsed = Html::parse(doc);
162 let link_tags = parsed.link_tags();
163
164 match link_tags.get("canonical").map(|x| Url::parse(x)) {
165 Some(Ok(mut parsed)) => {
167 parsed.set_fragment(None);
169 parsed.to_string()
170 }
171 _ => url.to_string(),
173 }
174}
175
176pub fn html_to_text(url: &str, doc: &str) -> ParseResult {
178 let parsed = Html::parse(doc);
179 let root = parsed.tree.root();
180 let meta = parsed.meta();
182 let link_tags = parsed.link_tags();
183 let title = parsed.title();
185 let mut content = String::from("");
186 let mut links = HashSet::new();
187 filter_text_nodes(&root, &mut content, &mut links);
188 content = content.trim().to_string();
190 links = links
192 .into_iter()
193 .flat_map(|href| normalize_href(url, &href))
194 .collect();
195
196 let mut description = if meta.contains_key("description") {
197 meta.get("description").unwrap().to_string()
198 } else if meta.contains_key("og:description") {
199 meta.get("og:description").unwrap().to_string()
200 } else {
201 "".to_string()
202 };
203
204 if description.is_empty() && !content.is_empty() {
205 let mut p_list = Vec::new();
207 filter_p_nodes(&root, &mut p_list);
208
209 let text = p_list.iter().find(|p_content| !p_content.trim().is_empty());
210 if text.is_some() && !text.unwrap().is_empty() {
211 description = text.unwrap_or(&String::from("")).trim().to_owned()
212 } else if !content.is_empty() {
213 description = content
215 .split(' ')
216 .take(DEFAULT_DESC_LENGTH)
217 .collect::<Vec<&str>>()
218 .join(" ")
219 }
220 }
221
222 let canonical_url = match link_tags.get("canonical").map(|x| Url::parse(x)) {
226 Some(Ok(mut parsed)) => {
228 parsed.set_fragment(None);
230 Some(parsed.to_string())
231 }
232 _ => Some(url.to_string()),
234 };
235
236 ParseResult::builder()
237 .canonical_url(canonical_url)
238 .content(content)
239 .description(description)
240 .links(links)
241 .meta(meta)
242 .title(title)
243 .build()
244}
245
246#[cfg(test)]
247mod test {
248 use super::{html_to_text, normalize_href};
249 use std::time::SystemTime;
250
251 #[test]
252 fn test_normalize_href() {
253 let url = "https://example.com";
254
255 assert_eq!(
256 normalize_href(url, "http://foo.com"),
257 Some("https://foo.com/".into())
258 );
259 assert_eq!(
260 normalize_href(url, "https://foo.com"),
261 Some("https://foo.com/".into())
262 );
263 assert_eq!(
264 normalize_href(url, "//foo.com"),
265 Some("https://foo.com/".into())
266 );
267 assert_eq!(
268 normalize_href(url, "/foo.html"),
269 Some("https://example.com/foo.html".into())
270 );
271 assert_eq!(
272 normalize_href(url, "/foo"),
273 Some("https://example.com/foo".into())
274 );
275 assert_eq!(
276 normalize_href(url, "foo.html"),
277 Some("https://example.com/foo.html".into())
278 );
279 }
280
281 #[test]
282 fn test_html_to_text() {
283 let html = include_str!("../../../../fixtures/html/raw.html");
284 let doc = html_to_text("https://oldschool.runescape.wiki", html);
285 assert_eq!(doc.title, Some("Old School RuneScape Wiki".to_string()));
286 assert_eq!(doc.meta.len(), 9);
287 assert!(!doc.content.is_empty());
288 assert_eq!(doc.links.len(), 58);
289 println!("{:?}", doc.links);
290 }
291
292 #[test]
293 fn test_html_to_text_large() {
294 let start = SystemTime::now();
295 let html = include_str!("../../../../fixtures/html/wikipedia_entry.html");
296 let doc = html_to_text("https://example.com", html);
297
298 let wall_time = start.elapsed().expect("elapsed");
299 println!("wall_time: {}ms", wall_time.as_millis());
300
301 assert_eq!(
302 doc.title,
303 Some("Rust (programming language) - Wikipedia".to_string())
304 );
305 }
306
307 #[test]
308 fn test_description_extraction() {
309 let html = include_str!("../../../../fixtures/html/wikipedia_entry.html");
310 let doc = html_to_text("https://example.com", html);
311
312 assert_eq!(
313 doc.title.unwrap(),
314 "Rust (programming language) - Wikipedia"
315 );
316 assert_eq!(doc.description, "Rust is a multi-paradigm , general-purpose programming language designed for performance and safety, especially safe concurrency . Rust is syntactically similar to C++ , but can guarantee memory safety by using a borrow checker to validate references . Rust achieves memory safety without garbage collection , and reference counting is optional. Rust has been called a systems programming language, and in addition to high-level features such as functional programming it also offers mechanisms for low-level memory management .");
317
318 let html = include_str!("../../../../fixtures/html/personal_blog.html");
319 let doc = html_to_text("https://example.com", html);
320 assert_eq!(doc.description, "2020 July 15 - San Francisco | 855 words");
322 }
323
324 #[test]
325 fn test_description_extraction_yc() {
326 let html = include_str!("../../../../fixtures/html/summary_test.html");
327 let doc = html_to_text("https://example.com", html);
328
329 assert_eq!(doc.title.unwrap(), "Why YC");
330 assert_eq!(doc.description, "March 2006, rev August 2009 Yesterday one of the founders we funded asked me why we started Y Combinator . Or more precisely, he asked if we'd started YC mainly for fun. Kind of, but not quite. It is enormously fun to be able to work with Rtm and Trevor again. I missed that after we sold Viaweb, and for all the years after I always had a background process running, looking for something we could do together. There is definitely an aspect of a band reunion to Y Combinator. Every couple days I slip and call it \"Viaweb.\" Viaweb we started very explicitly to make money. I was sick of living from one freelance project to the next, and decided to just work as hard as I could till I'd made enough to solve the problem once and for all. Viaweb was sometimes fun, but it wasn't designed for fun, and mostly it wasn't. I'd be surprised if any startup is. All startups are mostly schleps. The real reason we started Y Combinator is neither selfish nor virtuous. We didn't start it mainly to make money; we have no idea what our average returns might be, and won't know for years. Nor did we start YC mainly to help out young would-be founders, though we do like the idea, and comfort ourselves occasionally with the thought that if all our investments tank, we will thus have been doing something unselfish. (It's oddly nondeterministic.) The");
331 }
332}