Skip to main content

libnetrunner/parser/html/
mod.rs

1use 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    // Force HTTPS, crawler will fallback to HTTP if necessary.
16    if let Ok(url) = Url::parse(url) {
17        if href.starts_with("//") {
18            // schema relative url
19            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            // Force HTTPS, crawler will fallback to HTTP if necessary.
24            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            // origin or directory relative url
33            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
43/// Walk the DOM and grab all the p nodes
44fn 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
66/// Filters a DOM tree into a text document used for indexing
67fn filter_text_nodes(root: &NodeRef<Node>, doc: &mut String, links: &mut HashSet<String>) {
68    // TODO: move to config file? turn into a whitelist?
69    // TODO: Ignore list could also be updated per domain as well if needed
70    let ignore_list: HashSet<String> = HashSet::from([
71        "head".into(),
72        "sup".into(),
73        // Ignore elements that often don't contain relevant info
74        "header".into(),
75        "footer".into(),
76        "nav".into(),
77        // form elements
78        "label".into(),
79        "textarea".into(),
80        "input".into(),
81        // Ignore javascript/style nodes
82        "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        // Handle comments indicating we should skip parsing content nodes.
100        // Rare, but happens in wikipedia exports.
101        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            // Ignore elements on the ignore list
113            let element = node.as_element().unwrap();
114            if ignore_list.contains(&element.name()) {
115                continue;
116            }
117
118            // Ignore elements whose role is "navigation"
119            // TODO: Filter out full-list of ARIA roles that are not content
120            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            // Save links
129            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                // Ignore anchor links
138                if !href.starts_with('#')
139                    // ignore rels that tell us this link is not relevant
140                    && 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                // Add spacing after elements.
151                if !doc.ends_with(' ') {
152                    doc.push(' ');
153                }
154            }
155        }
156    }
157}
158
159/// Processes the html document and pulls out the canonical url
160pub 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        // Canonical URLs *must* be a full, valid URL
166        Some(Ok(mut parsed)) => {
167            // Ignore fragments
168            parsed.set_fragment(None);
169            parsed.to_string()
170        }
171        // Use the original URL if we are unable to determine the canonical URL from meta tags.
172        _ => url.to_string(),
173    }
174}
175
176/// Filters a DOM tree into a text document used for indexing
177pub fn html_to_text(url: &str, doc: &str) -> ParseResult {
178    let parsed = Html::parse(doc);
179    let root = parsed.tree.root();
180    // Meta tags
181    let meta = parsed.meta();
182    let link_tags = parsed.link_tags();
183    // Content
184    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    // Trim extra spaces from content
189    content = content.trim().to_string();
190    // Normalize links
191    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        // Extract first paragraph from content w/ text to use as the description
206        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            // Still nothing? Grab the first 256 words-ish
214            description = content
215                .split(' ')
216                .take(DEFAULT_DESC_LENGTH)
217                .collect::<Vec<&str>>()
218                .join(" ")
219        }
220    }
221
222    // If there's a canonical URL on this page, attempt to determine whether it's valid.
223    // More info about canonical URLS:
224    // https://developers.google.com/search/docs/advanced/crawling/consolidate-duplicate-urls
225    let canonical_url = match link_tags.get("canonical").map(|x| Url::parse(x)) {
226        // Canonical URLs *must* be a full, valid URL
227        Some(Ok(mut parsed)) => {
228            // Ignore fragments
229            parsed.set_fragment(None);
230            Some(parsed.to_string())
231        }
232        // Use the original URL if we are unable to determine the canonical URL from meta tags.
233        _ => 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        // ugh need to fix this
321        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}