use std::collections::HashMap;
use ego_tree::NodeRef;
use scraper::node::Node;
use scraper::{ElementRef, Html};
use url::Url;
use crate::extract;
use crate::types::UrlReference;
pub(crate) struct RefCollector {
seen: HashMap<String, usize>,
pub(crate) references: Vec<UrlReference>,
base: Option<Url>,
}
impl RefCollector {
pub(crate) fn new(base_url: &str) -> Self {
Self {
seen: HashMap::new(),
references: Vec::new(),
base: Url::parse(base_url).ok(),
}
}
pub(crate) fn resolve(&self, href: &str) -> Option<String> {
let href = href.trim();
if href.is_empty() || href.starts_with('#') {
return None;
}
let scheme = href.split(':').next().unwrap_or("").to_ascii_lowercase();
if matches!(scheme.as_str(), "javascript" | "mailto" | "data" | "tel") {
return None;
}
match &self.base {
Some(base) => base.join(href).ok().map(|u| u.to_string()),
None => Url::parse(href).ok().map(|u| u.to_string()),
}
}
pub(crate) fn index_for(&mut self, url: String, text: &str) -> usize {
if let Some(idx) = self.seen.get(&url) {
return *idx;
}
let idx = self.references.len() + 1;
self.seen.insert(url.clone(), idx);
self.references.push(UrlReference {
index: idx,
url,
text: text.trim().to_string(),
});
idx
}
}
fn is_block(name: &str) -> bool {
matches!(
name,
"p" | "div"
| "section"
| "article"
| "header"
| "footer"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "li"
| "ul"
| "ol"
| "table"
| "tr"
| "blockquote"
| "pre"
| "figure"
| "aside"
| "nav"
| "main"
)
}
const CELL_SEPARATOR: &str = " | ";
fn walk(node: NodeRef<Node>, out: &mut String, refs: &mut RefCollector) {
match node.value() {
Node::Text(t) => out.push_str(&t[..]),
Node::Element(el) => {
let name = el.name();
if super::is_skippable(name) {
return;
}
if name == "br" {
out.push('\n');
return;
}
if name == "a" {
let mut inner = String::new();
for child in node.children() {
walk(child, &mut inner, refs);
}
let inner = inner.trim().to_string();
out.push_str(&inner);
if let Some(href) = el.attr("href") {
if let Some(resolved) = refs.resolve(href) {
let idx = refs.index_for(resolved, &inner);
out.push_str(&format!(" [{}]", idx));
}
}
return;
}
if matches!(name, "td" | "th") {
if !out.is_empty() && !out.ends_with('\n') {
out.push_str(CELL_SEPARATOR);
}
for child in node.children() {
walk(child, out, refs);
}
return;
}
let block = is_block(name);
if block && !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
for child in node.children() {
walk(child, out, refs);
}
if block && !out.ends_with('\n') {
out.push('\n');
}
}
_ => {}
}
}
pub fn text_with_refs(doc: &Html, base_url: &str) -> (String, Vec<UrlReference>) {
let root: ElementRef = match extract::content_root(doc) {
Some(el) => el,
None => return (String::new(), Vec::new()),
};
let mut refs = RefCollector::new(base_url);
let mut out = String::new();
for child in root.children() {
walk(child, &mut out, &mut refs);
}
(out, refs.references)
}
pub fn html_to_text_with_refs(html: &str, base_url: &str) -> (String, Vec<UrlReference>) {
text_with_refs(&Html::parse_document(html), base_url)
}
pub fn render_references(references: &[UrlReference]) -> String {
crate::refs::render_block(references)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_cells_are_separated() {
let html = "<article><table>\
<tr><th>Name</th><th>Type</th></tr>\
<tr><td>alpha</td><td>string</td></tr>\
</table></article>";
let (text, _) = html_to_text_with_refs(html, "https://x.test/");
assert!(text.contains("Name | Type"), "text: {text:?}");
assert!(text.contains("alpha | string"), "text: {text:?}");
}
#[test]
fn unfetchable_schemes_get_no_reference() {
let html = r##"<article><p>
<a href="javascript:alert(1)">js</a>
<a href="mailto:a@b.c">mail</a>
<a href="#top">anchor</a>
<a href="/ok">ok</a></p></article>"##;
let (text, refs) = html_to_text_with_refs(html, "https://x.test/");
assert_eq!(refs.len(), 1, "refs: {refs:?}");
assert_eq!(refs[0].url, "https://x.test/ok");
assert!(text.contains("ok [1]"));
}
}